Container stacks per worktree: a pixel-art pane of every compose stack with its memory and worktree, orphans whose worktree is gone, and a guard that asks…

█▀▄ █▀█ █▀▀ █▄▀
█▄▀ █▄█ █▄▄ █ █ HEADROOM 1.5 GIB ▶ LOW FUEL

The problem: every git worktree boots its own docker compose stack, and nobody counts them. Delete a worktree and its stack keeps running, holding memory for code that no longer exists. Boot one more and the machine falls over. The study behind this library found 10 parallel container stacks crashing a machine, and orphaned stacks left behind by deleted worktrees.
dock shows every compose stack on the machine with the worktree it was booted from and the memory it uses, marks the ones whose worktree is gone as ORPHAN, and asks you before a new stack boots when the engine's memory headroom is low. It only reads docker. It never stops, removes or boots anything itself.
/plugin marketplace add pourya7/claude-code-mods
/plugin install dock@claude-code-mods
/dock opens a pane and reads docker: compose projects (docker compose ls), containers and their compose labels (docker ps), per-container memory (docker stats) and the engine's total memory (docker info). It reads again every intervalSeconds (30 by default) while the pane is open, and never while it is closed. Only one read runs at a time: if docker is slow to answer, the next refresh, [ RESCAN ], /dock and the guard wait for the read already running instead of starting another.UP 2, UP 1 OFF 1, OFF 3), a memory bar sized by its share of the engine, and its memory in GiB. Under it is the worktree it was booted from: the compose label com.docker.compose.project.working_dir, or the compose file's folder when no container carries the label. A ◆ marks the stack booted from this session's directory: the stack whose working directory is the deepest one holding it. A session in ~/work/app/.worktrees/feature-x marks the feature-x stack, not the main checkout's app stack too.ORPHAN in red. That is usually a stack from a deleted worktree.minHeadroomGiB. Headroom is the engine total minus what every running container uses, compose or not. The gauge and the headroom turn red when you are below the minimum.docker compose up or docker-compose up (with any flags, after cd … &&, sudo or VAR=1), dock reads docker first. If the headroom is below minHeadroomGiB (3 GiB by default), the call turns into a permission ask that names the biggest stacks and the command that would free the biggest one. At or above the minimum, the call goes ahead untouched. Other commands are never probed. A plugin that only asks for a verdict ($.tool.check, where nothing runs) gets the same answer from dock's last read, with no new docker read and no toast; dock reads docker for it only when it has not read docker yet this session.[ DOWN ] button. It copies the exact command, such as docker compose -p feature-x down, to your clipboard and shows it in a toast. Where no clipboard is available, it puts the command in your prompt box instead, but only when the box is empty: a draft you are typing is never touched, and the toast shows the command for you to run. dock never runs it: you decide./dock say so, and the guard steps aside.| Command | What it does |
|---|---|
/dock | Opens the pane, reads docker now, and replies with the same facts as text: memory used, headroom, one line per stack, and the command to free a running orphan. |
Pane buttons: [ DOWN ] per stack, [ RESCAN ] (hotkey r) and [ CLOSE ] (hotkey x). Hotkeys work once the pane has focus.
userConfig)| Field | Type | Default | Meaning |
|---|---|---|---|
intervalSeconds | number | 30 | How often the pane reads docker again while it is open (5 to 3600). |
guard | boolean | true | Ask before a Bash docker compose up / docker-compose up when the headroom is low. Off means dock never reads docker unless you open the pane. |
minHeadroomGiB | number | 3 | The free engine memory, in GiB, below which the guard asks. |
You can change these in the /config menu, or under pluginConfigs.dock in your settings.
The pane with three stacks, one of them an orphan holding 4 GiB, on an 8 GiB engine:
▀▀▀▀▀▀▀▀▀▀▀▀ D O C K
▀ ▀ ▀▀▀ MEM ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 6.5/8.0 GIB
▄▀▄▀▀▀▄▀▀▀▄▄ HEADROOM 1.5 GIB ▶ LOW FUEL (MIN 3)
feature-x UP 1 OFF 1 ▀▀▀▀▀▀▀▀ 4.0G [ DOWN ]
ORPHAN /work/app/.worktrees/feature-x
◆ app UP 2 ▀▀▀▀▀▀▀▀ 2.0G [ DOWN ]
/work/app
api OFF 2 ▀▀▀▀▀▀▀▀ 0.0G [ DOWN ]
/work/api
[ RESCAN ] [ CLOSE ] EVERY 30S WHILE OPEN · DOWN COPIES, NEVER RUNS
The guard's ask, when the model tries docker compose up -d in that state:
DOCK: LOW FUEL. Only 1.5 GiB free of 8.0 GiB; the minimum before booting another stack is 3 GiB.
Biggest stacks: feature-x 4.0 GiB (ORPHAN), app 2.0 GiB.
To free memory first: docker compose -p feature-x down
The /dock reply:
DOCK: 6.5 of 8.0 GiB used, 1.5 GiB headroom. LOW FUEL (under 3 GiB).
feature-x UP 1 OFF 1 4.0 GiB ORPHAN (/work/app/.worktrees/feature-x is gone)
app UP 2 2.0 GiB /work/app
api OFF 2 0.0 GiB /work/api
Free an orphan: docker compose -p feature-x down
These captures are plain text. In the terminal, the crane is navy with a blue container stack and an orange container on the hook. The gauge and bars are half-block pixels with a lit top row and a shadow row: blue while there is fuel, red past the red mark. Everything uses the PICO-8 palette.
VS Code and claude -p draw no pane, so the /dock reply and the guard's ask are what you see there.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
None. No $.http. | Yes, four read-only docker commands, and nothing else: docker info --format json, docker compose ls --all --format json, docker ps --all --no-trunc --format json and docker stats --no-stream --format json. They run on /dock, on [ RESCAN ], every intervalSeconds while the pane is open, and before each Bash docker compose up while guard is on. dock never runs down, stop, rm, kill, prune or up. | Checks whether each stack's working directory exists ($.fs.exists). Reads and writes no files. The last scan and the session's directory are kept for the session in $.state. Nothing goes to $.store. | No. dock makes no $.model call. | No. [ DOWN ] copies a command to your clipboard, or puts it in an empty prompt box when there is no clipboard (it reads the box first, with $.prompt.read, so it never overwrites a draft). It never sends it. | None of its own. The /dock reply is a command output the model reads, and the guard's ask reason (stack names, sizes, paths) goes to whatever decides permissions in your mode: the dialog, or the auto-mode classifier. |
docker compose up and docker-compose up typed into a Bash call. A stack booted by a script (make up, npm run dev) or by you outside Claude Code is not guarded, though it shows in the pane.MemTotal (the Docker Desktop VM's memory, or the host's on Linux) minus what running containers use now. It does not count the engine's own overhead, build caches, or memory a container will grow into after it boots.ORPHAN. A stack whose working directory dock cannot find out reads WORKTREE UNKNOWN, never ORPHAN.docker stats is slow. It samples for about two seconds, so each scan, and each guarded compose up, waits that long.ask; who answers that is your session's permission decider (the dialog, the auto-mode classifier, or a headless host), not dock.claude plugin validate dock
claude plugin test dock
Pure logic lives in hooks/docker.ts (the read-only probes, output parsers, stack joining, orphan marking, headroom and compose-up detection), hooks/text.ts (the guard's ask and the /dock reply) and hooks/pixels.ts (the half-block renderer, the crane, the fuel gauge and memory bars). hooks/register.tsx connects them to the engine. The tests answer process.run from recorded docker output, use mock.clock for the refresh timer, check that every docker command dock runs only reads, and mount the pane on both terminal and desktop.
hooks/register.tsx 349 lines1// dock: container stacks per worktree. Reads docker (never changes it), draws
2// every compose stack with its memory and worktree, and asks before a stack
3// boots when memory headroom is low.
4import { atom, read } from 'claude-code'
5import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
6
7import type { DockHealth, DockSnapshot } from '../types'
8import {
9 COMPOSE_LS_ARGV,
10 INFO_ARGV,
11 PS_ARGV,
12 STATS_ARGV,
13 describeDockerFailure,
14 downCommand,
15 headroomBytes,
16 isComposeUp,
17 markOrphans,
18 readInfo,
19 readStacks,
20} from './docker'
21import { CRANE, PALETTE, barGrid, gaugeGrid, pixelRows } from './pixels'
22import type { Run } from './pixels'
23import { gib, guardReason, hereDir, isLowFuel, offlineLine, shortenPath, summaryText } from './text'
24
25const PANE = 'dock'
26const DOCKER_TIMEOUT_MS = 20_000
27const GIB = 1024 ** 3
28
29const snapshotAtom = atom({ plugin: 'dock', key: 'snapshot' } as const, null as DockSnapshot | null)
30const cwdAtom = atom({ plugin: 'dock', key: 'cwd' } as const, '')
31
32type Ran = { stdout: string } | { health: DockHealth; message: string }
33
34// Timers cannot live in $.state, and a reload cancels them along with this
35// variable; session.start (raised on a reload too) arms it again while the pane is up.
36let timer: Timer | undefined
37
38function stopTimer(): void {
39 timer?.cancel()
40 timer = undefined
41}
42
43/** Runs one read-only docker probe; every failure becomes a health and a line. */
44async function docker($: EngineInterface, argv: readonly string[]): Promise<Ran> {
45 try {
46 const ran = await $.process.run(argv, { timeoutMs: DOCKER_TIMEOUT_MS })
47 if (ran.exitCode === 0) return { stdout: ran.stdout }
48 const serverError = readInfo(ran.stdout).serverError ?? ''
49 return describeDockerFailure(`${ran.stderr}\n${serverError}`.trim())
50 } catch (error) {
51 const message = error instanceof Error ? error.message : String(error)
52 if (/timed? ?out|timeout|still running/i.test(message)) {
53 return { health: 'error', message: `docker did not answer within ${DOCKER_TIMEOUT_MS / 1000}s` }
54 }
55 return { health: 'no-docker', message: `docker could not run (${message.slice(0, 120)})` }
56 }
57}
58
59/** Reads docker once, marks orphans on disk, records and returns the snapshot. */
60async function scan($: EngineInterface): Promise<DockSnapshot> {
61 const checkedAt = await $.clock.now()
62 const offline = (health: DockHealth, message: string): DockSnapshot => ({
63 health,
64 message,
65 checkedAt,
66 totalBytes: 0,
67 usedBytes: 0,
68 stacks: [],
69 })
70
71 const snapshot = await (async (): Promise<DockSnapshot> => {
72 const info = await docker($, INFO_ARGV)
73 if (!('stdout' in info)) return offline(info.health, info.message)
74 const engine = readInfo(info.stdout)
75 if (engine.serverError !== null) return offline('daemon-down', engine.serverError)
76 const outputs: string[] = []
77 for (const argv of [COMPOSE_LS_ARGV, PS_ARGV, STATS_ARGV]) {
78 const ran = await docker($, argv)
79 if (!('stdout' in ran)) return offline(ran.health, ran.message)
80 outputs.push(ran.stdout)
81 }
82 const [ls = '', ps = '', stats = ''] = outputs
83 const found = readStacks(ls, ps, stats)
84 const missing = new Set<string>()
85 for (const dir of new Set(found.stacks.flatMap(stack => (stack.workingDir === null ? [] : [stack.workingDir])))) {
86 try {
87 if (!(await $.fs.exists(dir))) missing.add(dir)
88 } catch {
89 // A path dock cannot check is not called an orphan.
90 }
91 }
92 return {
93 health: 'ok',
94 message: '',
95 checkedAt,
96 totalBytes: engine.totalBytes ?? found.limitBytes ?? 0,
97 usedBytes: found.usedBytes,
98 stacks: markOrphans(found.stacks, missing),
99 }
100 })()
101
102 await $.state.set({ plugin: 'dock', key: 'snapshot' }, snapshot)
103 return snapshot
104}
105
106// The docker scan in flight, if any. A reload starts this module afresh.
107let inFlight: Promise<DockSnapshot> | undefined
108
109/**
110 * One docker scan at a time: the timer, RESCAN, /dock and the guard share the
111 * scan still running instead of starting probes of their own, so a slow docker
112 * never stacks probes and an older scan never lands over a newer one.
113 */
114function scanShared($: EngineInterface): Promise<DockSnapshot> {
115 inFlight ??= scan($).finally(() => {
116 inFlight = undefined
117 })
118 return inFlight
119}
120
121async function isPaneUp($: EngineInterface): Promise<boolean> {
122 try {
123 return (await $.ui.panes()).some(pane => pane.id === PANE)
124 } catch {
125 return false
126 }
127}
128
129/** Reads docker every interval while the pane is up; stops itself once it is gone. */
130function arm($: EngineInterface, intervalMs: number): void {
131 if (timer !== undefined) return
132 timer = $.clock.every(intervalMs, () => {
133 void (async () => {
134 if (!(await isPaneUp($))) return stopTimer()
135 await scanShared($)
136 })().catch(stopTimer)
137 })
138}
139
140/**
141 * CLOSE: stops the timer, then closes. The pane's own `$.ui.close` skips this
142 * plugin's `ui.close` hook, which hears only the person's and the engine's closes.
143 */
144async function closePane($: EngineInterface): Promise<void> {
145 stopTimer()
146 await $.ui.close({ id: PANE })
147}
148
149/** DOWN: hands the person the exact command. dock never runs it. */
150async function offerDown($: EngineInterface, project: string, surface: RenderSurface): Promise<void> {
151 const command = downCommand(project)
152 let isCopied = false
153 try {
154 isCopied = (await $.ui.copy({ text: command, surface })).isCopied
155 } catch {
156 isCopied = false
157 }
158 if (isCopied) return $.ui.toast(`DOCK ▸ COPIED: ${command}`)
159 // Only an empty prompt box takes the command: a draft the person is typing is
160 // never touched. `append` keeps a draft even if the read came back blank.
161 let isFilled = false
162 try {
163 const draft = (await $.prompt.read()).text
164 if (draft.trim() === '') isFilled = (await $.prompt.fill({ text: command, mode: 'append' })).isFilled
165 } catch {
166 isFilled = false
167 }
168 $.ui.toast(isFilled ? `DOCK ▸ IN YOUR PROMPT BOX: ${command}` : `DOCK ▸ RUN IT YOURSELF: ${command}`)
169}
170
171export const register: Register = (on, options) => {
172 const intervalMs = Math.max(5, Number(options.intervalSeconds ?? 30) || 30) * 1000
173 const isGuardOn = options.guard !== false
174 const minGiB = Math.max(0, Number(options.minHeadroomGiB ?? 3) || 0)
175
176 on('session.start', async ($, e, next) => {
177 try {
178 await $.command.register({ name: 'dock', description: 'dock: container stacks per worktree, memory headroom and orphans' })
179 } catch {
180 $.ui.toast('DOCK: /dock could not be registered')
181 }
182 await $.state.set({ plugin: 'dock', key: 'cwd' }, e.cwd)
183 stopTimer()
184 if (await isPaneUp($)) arm($, intervalMs)
185 return next(e)
186 })
187
188 on('command.run', { command: 'dock' }, async $ => {
189 try {
190 await $.ui.open({ id: PANE, title: 'DOCK' })
191 } catch {
192 // No pane on this surface: the reply below carries the same facts.
193 }
194 const snapshot = await scanShared($)
195 arm($, intervalMs)
196 return { text: summaryText(snapshot, minGiB) }
197 })
198
199 on('ui.close', async ($, e, next) => {
200 const result = await next(e)
201 if (e.id === PANE) stopTimer()
202 return result
203 })
204
205 on('tool.check', async ($, e, next) => {
206 const decided = await next(e)
207 if (!isGuardOn || decided.decision === 'deny' || e.tool !== 'Bash') return decided
208 const command = typeof e.input === 'object' && e.input !== null ? (e.input as { command?: unknown }).command : undefined
209 if (typeof command !== 'string' || !isComposeUp(command)) return decided
210 // A query ($.tool.check, no tool_use_id) runs nothing: it gets the same
211 // verdict from the last scan when there is one, and never a toast.
212 const isQuery = e.tool_use_id === undefined
213 try {
214 const last = isQuery ? await read($, snapshotAtom) : null
215 const snapshot = last ?? (await scanShared($))
216 if (!isLowFuel(snapshot, minGiB)) return decided
217 if (!isQuery) $.ui.toast(`DOCK ▸ LOW FUEL: ${gib(headroomBytes(snapshot))} GIB FREE`)
218 return { decision: 'ask', reason: guardReason(snapshot, minGiB) }
219 } catch {
220 return decided
221 }
222 })
223
224 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
225 const { Box, Button, Text } = $.ui.resolve(e)
226 const snapshot = await read($, snapshotAtom)
227 const cwd = await read($, cwdAtom)
228 const columns = Math.max(30, e.props.bodyColumns)
229
230 const pixels = (grid: readonly string[]) =>
231 pixelRows(grid).map(runs => (
232 <Box flexDirection="row">
233 {runs.map((run: Run) => (
234 <Text color={run.color} backgroundColor={run.backgroundColor}>
235 {run.text}
236 </Text>
237 ))}
238 </Box>
239 ))
240
241 const closeButton = <Button key="close" label="CLOSE" hotkey="x" role="dismiss" onPress={() => closePane($)} />
242
243 const isOk = snapshot !== null && snapshot.health === 'ok'
244 const isLow = snapshot !== null && isLowFuel(snapshot, minGiB)
245 const gaugeWidth = Math.max(8, Math.min(24, columns - 40))
246 const total = snapshot?.totalBytes ?? 0
247 const redLine = total > 0 ? Math.max(0, total - minGiB * GIB) / total : 1
248
249 const header = (
250 <Box flexDirection="row">
251 <Box key="crane" flexDirection="column" marginRight={2}>
252 {pixels(CRANE)}
253 </Box>
254 <Box flexDirection="column">
255 <Text bold color={PALETTE.u}>
256 D O C K
257 </Text>
258 {isOk && snapshot ? (
259 <Box key="gauge" flexDirection="row">
260 <Text color={PALETTE.l}>MEM </Text>
261 <Box flexDirection="column">{pixels(gaugeGrid(total > 0 ? snapshot.usedBytes / total : 0, redLine, gaugeWidth))}</Box>
262 <Text color={PALETTE.l}>
263 {' '}
264 {gib(snapshot.usedBytes)}/{gib(total)} GIB
265 </Text>
266 </Box>
267 ) : null}
268 {isOk && snapshot ? (
269 <Box key="headroom">
270 <Text bold color={isLow ? PALETTE.r : PALETTE.u}>
271 {`HEADROOM ${gib(headroomBytes(snapshot))} GIB ${isLow ? `▶ LOW FUEL (MIN ${minGiB})` : '▶ FUEL OK'}`}
272 </Text>
273 </Box>
274 ) : null}
275 </Box>
276 </Box>
277 )
278
279 if (!isOk || snapshot === null) {
280 return (
281 <Box flexDirection="column">
282 {header}
283 <Box key="offline" marginTop={1}>
284 <Text color={snapshot === null ? PALETTE.l : PALETTE.r}>
285 {snapshot === null ? 'SCANNING DOCKER... (OR /dock TO INSERT COIN)' : offlineLine(snapshot)}
286 </Text>
287 </Box>
288 <Box flexDirection="row" marginTop={1}>
289 <Button key="rescan" label="RESCAN" hotkey="r" onPress={() => scanShared($)} />
290 <Text> </Text>
291 {closeButton}
292 </Box>
293 </Box>
294 )
295 }
296
297 const nameWidth = Math.min(18, Math.max(5, ...snapshot.stacks.map(stack => stack.name.length)))
298 const stateWidth = Math.max(4, ...snapshot.stacks.map(stack => stack.stateLabel.length))
299 const pathWidth = Math.max(10, columns - 4)
300 const mine = hereDir(
301 snapshot.stacks.map(stack => stack.workingDir),
302 cwd,
303 )
304
305 return (
306 <Box flexDirection="column">
307 {header}
308 {snapshot.stacks.length === 0 ? (
309 <Box key="empty" marginTop={1}>
310 <Text color={PALETTE.l}>NO STACKS IN PORT. docker compose up TO DOCK ONE</Text>
311 </Box>
312 ) : null}
313 {snapshot.stacks.map(stack => {
314 const isMine = stack.workingDir !== null && stack.workingDir === mine
315 const name = stack.name.length > nameWidth ? `${stack.name.slice(0, nameWidth - 1)}~` : stack.name.padEnd(nameWidth)
316 const where = stack.isOrphan
317 ? `ORPHAN ${shortenPath(stack.workingDir ?? '', pathWidth - 7)}`
318 : shortenPath(stack.workingDir ?? 'WORKTREE UNKNOWN', pathWidth)
319 return (
320 <Box key={`stack-${stack.name}`} flexDirection="column" marginTop={1}>
321 <Box flexDirection="row">
322 <Text color={isMine ? PALETTE.y : PALETTE.n}>{isMine ? '◆ ' : ' '}</Text>
323 <Text bold color={stack.isOrphan ? PALETTE.r : PALETTE.w}>
324 {name}
325 </Text>
326 <Text color={stack.isRunning ? PALETTE.e : PALETTE.d}> {stack.stateLabel.padEnd(stateWidth)} </Text>
327 <Box flexDirection="column">
328 {pixels(barGrid(total > 0 ? stack.memoryBytes / total : 0, 8, stack.isOrphan ? 'r' : 'u'))}
329 </Box>
330 <Text color={PALETTE.l}> {gib(stack.memoryBytes).padStart(4)}G </Text>
331 <Button key={`down-${stack.name}`} label="DOWN" onPress={press => offerDown($, stack.name, press.surface)} />
332 </Box>
333 <Box key={`where-${stack.name}`}>
334 <Text color={stack.isOrphan ? PALETTE.r : PALETTE.l}>{` ${where}`}</Text>
335 </Box>
336 </Box>
337 )
338 })}
339 <Box flexDirection="row" marginTop={1}>
340 <Button key="rescan" label="RESCAN" hotkey="r" onPress={() => scanShared($)} />
341 <Text> </Text>
342 {closeButton}
343 <Text color={PALETTE.d}> EVERY {String(intervalMs / 1000)}S WHILE OPEN · DOWN COPIES, NEVER RUNS</Text>
344 </Box>
345 </Box>
346 )
347 })
348}
349hooks/docker.ts 262 lines1// docker: the read-only probes dock runs, their output readers, headroom math
2// and compose-up detection. Pure: no `$` here.
3import type { DockHealth, DockStack } from '../types'
4
5export const INFO_ARGV = ['docker', 'info', '--format', 'json'] as const
6export const COMPOSE_LS_ARGV = ['docker', 'compose', 'ls', '--all', '--format', 'json'] as const
7export const PS_ARGV = ['docker', 'ps', '--all', '--no-trunc', '--format', 'json'] as const
8export const STATS_ARGV = ['docker', 'stats', '--no-stream', '--format', 'json'] as const
9
10/** Every command dock runs, in the order it runs them. None of them changes anything. */
11export const READ_ONLY_ARGVS: readonly (readonly string[])[] = [INFO_ARGV, COMPOSE_LS_ARGV, PS_ARGV, STATS_ARGV]
12
13/** True only for one of dock's own probes, exactly as written. */
14export function isReadOnlyArgv(argv: readonly string[]): boolean {
15 return READ_ONLY_ARGVS.some(known => known.length === argv.length && known.every((word, index) => argv[index] === word))
16}
17
18type Json = Record<string, unknown>
19
20const isRecord = (value: unknown): value is Json => typeof value === 'object' && value !== null && !Array.isArray(value)
21
22function tryParse(text: string): unknown {
23 try {
24 return JSON.parse(text)
25 } catch {
26 return undefined
27 }
28}
29
30/** Objects from a JSON array, one JSON object, or one JSON object per line (what `--format json` prints). */
31export function parseRecords(text: string): Json[] {
32 const whole = tryParse(text.trim())
33 if (Array.isArray(whole)) return whole.filter(isRecord)
34 if (isRecord(whole)) return [whole]
35 return text
36 .split('\n')
37 .map(line => tryParse(line.trim()))
38 .filter(isRecord)
39}
40
41const UNITS: Record<string, number> = {
42 b: 1,
43 kb: 1e3,
44 mb: 1e6,
45 gb: 1e9,
46 tb: 1e12,
47 kib: 1024,
48 mib: 1024 ** 2,
49 gib: 1024 ** 3,
50 tib: 1024 ** 4,
51}
52
53/** `1.5GiB`, `512MiB`, `20kB`, or the first half of `1.5GiB / 7.6GiB`, in bytes; null when unreadable. */
54export function parseBytes(text: string): number | null {
55 const match = /^\s*([\d.]+)\s*([a-z]+)/i.exec(text)
56 if (!match) return null
57 const value = Number(match[1])
58 const unit = UNITS[(match[2] ?? '').toLowerCase()]
59 return Number.isFinite(value) && unit !== undefined ? value * unit : null
60}
61
62/** `a=1,b=2` into a map; a piece without `=` belongs to the value before it (a comma in a path). */
63export function parseLabels(text: string): Record<string, string> {
64 const labels: Record<string, string> = {}
65 let last: string | null = null
66 for (const piece of text.split(',')) {
67 const equals = piece.indexOf('=')
68 if (equals > 0) {
69 last = piece.slice(0, equals)
70 labels[last] = piece.slice(equals + 1)
71 } else if (last !== null) {
72 labels[last] += `,${piece}`
73 }
74 }
75 return labels
76}
77
78/** `docker info --format json`: the engine's memory, or the server error when the daemon is down. */
79export function readInfo(text: string): { totalBytes: number | null; serverError: string | null } {
80 const info = parseRecords(text)[0]
81 const errors = info?.ServerErrors
82 const serverError = Array.isArray(errors) && errors.length > 0 ? String(errors[0]) : null
83 const total = info?.MemTotal
84 return { totalBytes: typeof total === 'number' && total > 0 ? total : null, serverError }
85}
86
87const STATE_WORDS: Record<string, string> = {
88 running: 'UP',
89 exited: 'OFF',
90 paused: 'PAUSED',
91 restarting: 'RESTART',
92 created: 'NEW',
93 dead: 'DEAD',
94 removing: 'GOING',
95}
96
97/** Compose's `running(1), exited(1)` as `UP 1 OFF 1`. */
98export function stateLabel(status: string): string {
99 const parts = [...status.matchAll(/([a-z]+)\((\d+)\)/gi)].map(
100 ([, word = '', count = '']) => `${STATE_WORDS[word.toLowerCase()] ?? word.toUpperCase()} ${count}`,
101 )
102 return parts.length > 0 ? parts.join(' ') : status.trim() === '' ? '?' : status.trim().toUpperCase()
103}
104
105function countOf(status: string, word: string): number {
106 return [...status.matchAll(/([a-z]+)\((\d+)\)/gi)]
107 .filter(([, found]) => found?.toLowerCase() === word)
108 .reduce((sum, [, , count]) => sum + Number(count), 0)
109}
110
111function totalCount(status: string): number {
112 return [...status.matchAll(/\((\d+)\)/g)].reduce((sum, [, count]) => sum + Number(count), 0)
113}
114
115function folderOf(path: string): string | null {
116 const slash = path.lastIndexOf('/')
117 return slash > 0 ? path.slice(0, slash) : slash === 0 ? '/' : null
118}
119
120const text = (value: unknown): string => (typeof value === 'string' ? value : value === undefined || value === null ? '' : String(value))
121
122export type StackRead = { stacks: DockStack[]; usedBytes: number; limitBytes: number | null }
123
124/**
125 * Joins `docker compose ls`, `docker ps` and `docker stats` into one row per
126 * compose project: its working dir (label, else the compose file's folder),
127 * state, and the memory its running containers use. Biggest first.
128 */
129export function readStacks(lsText: string, psText: string, statsText: string): StackRead {
130 const containers = parseRecords(psText).map(row => {
131 const labels = parseLabels(text(row.Labels))
132 return {
133 id: text(row.ID),
134 names: text(row.Names).split(','),
135 project: labels['com.docker.compose.project'] ?? null,
136 workingDir: labels['com.docker.compose.project.working_dir'] ?? null,
137 }
138 })
139
140 const memory = new Map<string, number>()
141 let usedBytes = 0
142 let limitBytes: number | null = null
143 for (const row of parseRecords(statsText)) {
144 const usage = text(row.MemUsage)
145 const used = parseBytes(usage) ?? 0
146 usedBytes += used
147 const limit = usage.includes('/') ? parseBytes(usage.slice(usage.indexOf('/') + 1)) : null
148 if (limitBytes === null && limit !== null && limit > 0) limitBytes = limit
149 const name = text(row.Name)
150 const id = text(row.ID || row.Container)
151 const owner =
152 containers.find(container => container.names.includes(name)) ??
153 (id === '' ? undefined : containers.find(container => container.id.startsWith(id) || id.startsWith(container.id)))
154 if (owner?.project) memory.set(owner.project, (memory.get(owner.project) ?? 0) + used)
155 }
156
157 const stacks: DockStack[] = parseRecords(lsText).map(row => {
158 const name = text(row.Name)
159 const status = text(row.Status)
160 const labelled = containers.find(container => container.project === name && container.workingDir)?.workingDir ?? null
161 const configFile = text(row.ConfigFiles).split(',')[0]?.trim() ?? ''
162 return {
163 name,
164 workingDir: labelled ?? (configFile === '' ? null : folderOf(configFile)),
165 isOrphan: false,
166 stateLabel: stateLabel(status),
167 isRunning: countOf(status, 'running') > 0,
168 containers: totalCount(status),
169 memoryBytes: memory.get(name) ?? 0,
170 }
171 })
172 stacks.sort((a, b) => b.memoryBytes - a.memoryBytes || a.name.localeCompare(b.name))
173
174 return { stacks, usedBytes, limitBytes }
175}
176
177/** Marks the stacks whose (known) working dir is in `missingDirs`. */
178export function markOrphans(stacks: readonly DockStack[], missingDirs: ReadonlySet<string>): DockStack[] {
179 return stacks.map(stack => ({ ...stack, isOrphan: stack.workingDir !== null && missingDirs.has(stack.workingDir) }))
180}
181
182/** Free engine memory in bytes: total minus what every container uses, never below zero. */
183export function headroomBytes(memory: { totalBytes: number; usedBytes: number }): number {
184 return Math.max(0, memory.totalBytes - memory.usedBytes)
185}
186
187/** The running stacks using the most memory, biggest first. */
188export function biggestStacks(stacks: readonly DockStack[], count: number): DockStack[] {
189 return stacks
190 .filter(stack => stack.isRunning && stack.memoryBytes > 0)
191 .sort((a, b) => b.memoryBytes - a.memoryBytes)
192 .slice(0, count)
193}
194
195const DOCKER_VALUE_FLAGS = new Set(['-c', '--context', '-H', '--host', '--config', '-l', '--log-level', '--tlscacert', '--tlscert', '--tlskey'])
196const COMPOSE_VALUE_FLAGS = new Set([
197 '-f',
198 '--file',
199 '-p',
200 '--project-name',
201 '--project-directory',
202 '--env-file',
203 '--profile',
204 '--ansi',
205 '--parallel',
206 '--progress',
207])
208
209const unquote = (word: string) => word.replace(/^['"]|['"]$/g, '')
210
211/** The compose subcommand after `compose`'s own flags, or null. */
212function composeVerb(words: readonly string[], from: number): string | null {
213 for (let index = from; index < words.length; index += 1) {
214 const word = words[index] ?? ''
215 if (!word.startsWith('-')) return word
216 if (!word.includes('=') && COMPOSE_VALUE_FLAGS.has(word)) index += 1
217 }
218 return null
219}
220
221/** True when one simple command boots a compose stack. */
222function segmentIsUp(segment: string): boolean {
223 const words = segment.trim().split(/\s+/).filter(Boolean).map(unquote)
224 // The program is the first word after env assignments and sudo/env/command wrappers.
225 let start = 0
226 while (start < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[start] ?? '') || ['sudo', 'env', 'command', 'exec'].includes(words[start] ?? ''))) {
227 start += 1
228 }
229 const program = words[start]
230 if (program === 'docker-compose') return composeVerb(words, start + 1) === 'up'
231 if (program !== 'docker') return false
232 for (let index = start + 1; index < words.length; index += 1) {
233 const word = words[index] ?? ''
234 if (word === 'compose') return composeVerb(words, index + 1) === 'up'
235 if (!word.startsWith('-')) return false
236 if (!word.includes('=') && DOCKER_VALUE_FLAGS.has(word)) index += 1
237 }
238 return false
239}
240
241/** True when a Bash command runs `docker compose up` or `docker-compose up` anywhere in it. */
242export function isComposeUp(command: string): boolean {
243 return command.split(/&&|\|\||[;|&\n()]/).some(segmentIsUp)
244}
245
246const SAFE_WORD = /^[A-Za-z0-9_.-]+$/
247
248/** The exact command that stops and removes one stack, for the person to run. dock never runs it. */
249export function downCommand(project: string): string {
250 const quoted = SAFE_WORD.test(project) ? project : `'${project.replace(/'/g, `'\\''`)}'`
251 return `docker compose -p ${quoted} down`
252}
253
254const DAEMON_DOWN = /cannot connect to the docker daemon|is the docker daemon running|error during connect|docker daemon is not running|docker\.sock/i
255
256/** A docker run that exited non-zero, as a health and one line. */
257export function describeDockerFailure(stderr: string): { health: DockHealth; message: string } {
258 const first = stderr.trim().split('\n')[0]?.trim() ?? ''
259 if (DAEMON_DOWN.test(stderr)) return { health: 'daemon-down', message: first }
260 return { health: 'error', message: first === '' ? 'docker failed with no message' : first.slice(0, 200) }
261}
262hooks/pixels.ts 104 lines1/**
2 * Half-block pixel art: a sprite is a grid of palette keys ('.' is
3 * transparent); two pixel rows fold into one text row, the top pixel as the
4 * `▀`'s color and the bottom one as its background.
5 */
6
7/** PICO-8, keyed by one letter. */
8export const PALETTE = {
9 k: '#000000', // black
10 n: '#1D2B53', // navy: dock's signature, with blue
11 m: '#7E2553', // plum
12 g: '#008751', // green
13 b: '#AB5236', // brown
14 d: '#5F574F', // dark grey
15 l: '#C2C3C7', // light grey
16 w: '#FFF1E8', // white
17 r: '#FF004D', // red
18 o: '#FFA300', // orange
19 y: '#FFEC27', // yellow
20 e: '#00E436', // lime
21 u: '#29ADFF', // blue: dock's signature, with navy
22 v: '#83769C', // lavender
23 i: '#FF77A8', // pink
24 c: '#FFCCAA', // peach
25} as const
26
27export type PaletteKey = keyof typeof PALETTE
28
29export type Run = { text: string; color?: string; backgroundColor?: string }
30
31const colorOf = (key: string | undefined): string | undefined =>
32 key === undefined || key === '.' ? undefined : (PALETTE as Record<string, string>)[key]
33
34const cellOf = (top: string | undefined, bottom: string | undefined): Run => {
35 if (top && bottom) return { text: '▀', color: top, backgroundColor: bottom }
36 if (top) return { text: '▀', color: top }
37 if (bottom) return { text: '▄', color: bottom }
38 return { text: ' ' }
39}
40
41const sameStyle = (a: Run, b: Run): boolean => a.color === b.color && a.backgroundColor === b.backgroundColor
42
43/** Folds a grid into text rows of styled runs, merging neighbours of one style. */
44export const pixelRows = (grid: readonly string[]): Run[][] => {
45 const rows: Run[][] = []
46 const width = Math.max(0, ...grid.map(line => line.length))
47 for (let y = 0; y < grid.length; y += 2) {
48 const runs: Run[] = []
49 for (let x = 0; x < width; x += 1) {
50 const cell = cellOf(colorOf(grid[y]?.[x]), colorOf(grid[y + 1]?.[x]))
51 const last = runs[runs.length - 1]
52 if (last && last.text[0] === cell.text && sameStyle(last, cell)) last.text += cell.text
53 else runs.push({ ...cell })
54 }
55 rows.push(runs)
56 }
57 return rows
58}
59
60/** A harbour crane lifting an orange container onto a blue stack: 12x6, 3 text rows. */
61export const CRANE: readonly string[] = [
62 'nnnnnnnnnnnn',
63 '.nn.....l...',
64 '.n.n...ooo..',
65 '.n.....ooo..',
66 '.n.uuu.uuu..',
67 'nnnnnnnnnnnn',
68]
69
70/** The darker pixel under each lit one, so a bar reads as lit top, shadow bottom. */
71const SHADE: Partial<Record<PaletteKey, PaletteKey>> = { u: 'n', r: 'm', o: 'b', e: 'g', l: 'd' }
72
73const clamp = (value: number) => Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0))
74
75/**
76 * The fuel gauge: two pixel rows, `width` wide, filled by `used` (0..1 of the
77 * engine). The red line sits where headroom runs low (`redLine`, 0..1); a
78 * fill past it turns red.
79 */
80export const gaugeGrid = (used: number, redLine: number, width: number): string[] => {
81 const filled = Math.round(clamp(used) * width)
82 const isPast = clamp(used) > clamp(redLine)
83 const lit: PaletteKey = isPast ? 'r' : 'u'
84 const marker = Math.min(width - 1, Math.max(0, Math.round(clamp(redLine) * width) - 1))
85 let top = ''
86 let bottom = ''
87 for (let x = 0; x < width; x += 1) {
88 const isFilled = x < filled
89 top += isFilled ? lit : 'd'
90 bottom += isFilled ? (SHADE[lit] ?? lit) : x === marker ? 'r' : 'k'
91 }
92 return [top, bottom]
93}
94
95/** One stack's share of the engine as a small bar; any memory at all lights one pixel. */
96export const barGrid = (share: number, width: number, lit: PaletteKey = 'u'): string[] => {
97 const value = clamp(share)
98 const filled = value > 0 ? Math.max(1, Math.round(value * width)) : 0
99 const shade = SHADE[lit] ?? lit
100 return ['', ''].map((_, row) =>
101 Array.from({ length: width }, (_unused, x) => (x < filled ? (row === 0 ? lit : shade) : row === 0 ? 'd' : 'k')).join(''),
102 )
103}
104hooks/text.ts 80 lines1// text: what dock says in words, for the guard's ask, the /dock reply and
2// the pane. Pure: no `$` here.
3import type { DockSnapshot } from '../types'
4import { biggestStacks, downCommand, headroomBytes } from './docker'
5
6const GIB = 1024 ** 3
7
8/** Bytes as GiB with one decimal: `2.5`. */
9export function gib(bytes: number): string {
10 return (bytes / GIB).toFixed(1)
11}
12
13/** True when docker was read and the headroom is under `minGiB`. */
14export function isLowFuel(snapshot: DockSnapshot, minGiB: number): boolean {
15 return snapshot.health === 'ok' && headroomBytes(snapshot) < minGiB * GIB
16}
17
18/** The ask the guard puts to the person before another stack boots. */
19export function guardReason(snapshot: DockSnapshot, minGiB: number): string {
20 const biggest = biggestStacks(snapshot.stacks, 3)
21 const named = biggest.map(stack => `${stack.name} ${gib(stack.memoryBytes)} GiB${stack.isOrphan ? ' (ORPHAN)' : ''}`)
22 const first = biggest.find(stack => stack.isOrphan) ?? biggest[0]
23 return [
24 `DOCK: LOW FUEL. Only ${gib(headroomBytes(snapshot))} GiB free of ${gib(snapshot.totalBytes)} GiB; the minimum before booting another stack is ${minGiB} GiB.`,
25 named.length > 0 ? `Biggest stacks: ${named.join(', ')}.` : 'No compose stack holds much memory; something else does.',
26 first ? `To free memory first: ${downCommand(first.name)}` : '',
27 ]
28 .filter(Boolean)
29 .join('\n')
30}
31
32/** Why docker could not be read, as an arcade line. */
33export function offlineLine(snapshot: DockSnapshot): string {
34 if (snapshot.health === 'no-docker') return 'NO DOCKER FOUND. INSTALL IT OR PUT IT ON PATH'
35 if (snapshot.health === 'daemon-down') return 'DOCKER DAEMON DOWN. START DOCKER, THEN RESCAN'
36 return `DOCKER ERROR: ${snapshot.message}`
37}
38
39/** The `/dock` reply: the same facts as the pane, as plain text. */
40export function summaryText(snapshot: DockSnapshot, minGiB: number): string {
41 if (snapshot.health !== 'ok') return `DOCK: ${offlineLine(snapshot)}`
42 const headroom = gib(headroomBytes(snapshot))
43 const fuel = isLowFuel(snapshot, minGiB) ? `. LOW FUEL (under ${minGiB} GiB).` : '.'
44 const lines = [`DOCK: ${gib(snapshot.usedBytes)} of ${gib(snapshot.totalBytes)} GiB used, ${headroom} GiB headroom${fuel}`]
45 if (snapshot.stacks.length === 0) return [...lines, 'NO STACKS. Nothing from docker compose is here.'].join('\n')
46 const nameWidth = Math.max(...snapshot.stacks.map(stack => stack.name.length))
47 const stateWidth = Math.max(...snapshot.stacks.map(stack => stack.stateLabel.length))
48 for (const stack of snapshot.stacks) {
49 const where = stack.isOrphan ? `ORPHAN (${stack.workingDir} is gone)` : (stack.workingDir ?? '?')
50 lines.push(`${stack.name.padEnd(nameWidth)} ${stack.stateLabel.padEnd(stateWidth)} ${gib(stack.memoryBytes).padStart(5)} GiB ${where}`)
51 }
52 const orphan = snapshot.stacks.find(stack => stack.isOrphan && stack.isRunning)
53 if (orphan) lines.push(`Free an orphan: ${downCommand(orphan.name)}`)
54 return lines.join('\n')
55}
56
57/** A path cut to `width` columns, keeping its tail. */
58export function shortenPath(path: string, width: number): string {
59 if (path.length <= width) return path
60 return `..${path.slice(path.length - Math.max(0, width - 2))}`
61}
62
63/** True when the session works in this working dir (or below it). */
64export function isHere(workingDir: string | null, cwd: string): boolean {
65 if (workingDir === null || cwd === '') return false
66 return cwd === workingDir || cwd.startsWith(`${workingDir.replace(/\/$/, '')}/`)
67}
68
69/**
70 * The working dir the session was booted from: of the dirs that hold the cwd,
71 * the deepest. A worktree nested in its main checkout picks the worktree, not both.
72 */
73export function hereDir(workingDirs: readonly (string | null)[], cwd: string): string | null {
74 let best: string | null = null
75 for (const dir of workingDirs) {
76 if (dir !== null && isHere(dir, cwd) && (best === null || dir.length > best.length)) best = dir
77 }
78 return best
79}
80types/index.d.ts 43 lines1/** What dock could read from docker: `ok`, or why not. */
2export type DockHealth = 'ok' | 'no-docker' | 'daemon-down' | 'error'
3
4/** One compose project, as `docker compose ls` names it. */
5export type DockStack = {
6 name: string
7 /** The compose working dir (label `com.docker.compose.project.working_dir`), or null when unknown. */
8 workingDir: string | null
9 /** The working dir no longer exists: the worktree that booted it is gone. */
10 isOrphan: boolean
11 /** Compose's status as a short label, e.g. `UP 2` or `UP 1 OFF 1`. */
12 stateLabel: string
13 isRunning: boolean
14 containers: number
15 /** Memory its running containers use now, in bytes. */
16 memoryBytes: number
17}
18
19/** One read of docker. */
20export type DockSnapshot = {
21 health: DockHealth
22 /** Why docker could not be read; empty when `health` is ok. */
23 message: string
24 /** When it was read, in ms since the epoch. */
25 checkedAt: number
26 /** Memory the engine has, in bytes (`docker info` MemTotal). */
27 totalBytes: number
28 /** Memory every running container uses, in bytes, stack or not. */
29 usedBytes: number
30 /** Biggest first. */
31 stacks: DockStack[]
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 dock: {
37 snapshot: DockSnapshot | null
38 /** The session's working directory, to mark the stack booted from here. */
39 cwd: string
40 }
41 }
42}
43