SLOPSHOPPER

dock

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…

newpaneguardcommandtoastprocess
v0.1.0MITupdated 2026-10-04pourya7/claude-code-mods/dock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dock
│ ┃ DOCK ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ D O C K │ ┃ ▀ ▀ ▀▀▀ MEM ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0.0/0.0 G ⏺ Read(src/auth.ts) │ ┃ ▄▀▄▀▀▀▄▀▀▀▄▄ HEADROOM 0.0 GIB ▶ LOW FUEL (M ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ NO STACKS IN PORT. docker compose up TO DOCK ⎿ Added 2 lines, removed 1 line │ ┃ ONE ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ [ RESCAN ] [ CLOSE ] EVERY 30S WHILE OPEN · │ ┃ RUNS ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dock │ ⎿ dock: DOCK: 0.0 of 0.0 GiB used, 0.0 GiB headroom. LOW FUEL (und │ ⎿ dock: NO STACKS. Nothing from docker compose is here. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · DOCK
▀▀▀▀▀▀▀▀▀▀▀▀ D O C K ▀ ▀ ▀▀▀ MEM ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ 0.0/0.0 GIB ▄▀▄▀▀▀▄▀▀▀▄▄ HEADROOM 0.0 GIB ▶ LOW FUEL (MIN 3) NO STACKS IN PORT. docker compose up TO DOCK ONE [ RESCAN ] [ CLOSE ] EVERY 30S WHILE OPEN · DOWN COPIES, NEV
README
█▀▄ █▀█ █▀▀ █▄▀
█▄▀ █▄█ █▄▄ █ █   HEADROOM 1.5 GIB ▶ LOW FUEL

dock's guard turning a docker compose up into an ask at 1.8 GiB headroom, then the /dock pane with its fuel gauge, three stacks and the ORPHAN row

dock — container stacks per worktree

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.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install dock@claude-code-mods

How it behaves

  • The pane. /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.
  • Rows. One row per compose project, biggest first: its name, its state (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.
  • Orphans. When a stack's working directory no longer exists on disk, the row says ORPHAN in red. That is usually a stack from a deleted worktree.
  • Fuel gauge. The header shows memory in use against the engine total as a pixel-art gauge, with a red mark where the headroom drops below 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.
  • Guard. When the model runs a Bash command containing 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. Each row has a [ 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.
  • No docker. If docker is not installed, or the daemon is not running, the pane and /dock say so, and the guard steps aside.

Commands

CommandWhat it does
/dockOpens 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.

Configuration (userConfig)

FieldTypeDefaultMeaning
intervalSecondsnumber30How often the pane reads docker again while it is open (5 to 3600).
guardbooleantrueAsk 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.
minHeadroomGiBnumber3The 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 UI

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.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData 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.

Limits

  • The guard reads the command text. It sees 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.
  • What headroom means. It is the engine's 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.
  • Orphans are judged on this disk. A stack on a remote docker context, or one whose folder was moved, also reads 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.
  • Hot reload. A reload cancels the refresh timer. dock arms it again when it reloads if the pane is still open.
  • Asks follow your mode. dock answers ask; who answers that is your session's permission decider (the dialog, the auto-mode classifier, or a headless host), not dock.

Development

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.

Source 5 files
hooks/register.tsx 349 lines
1// 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}
349
hooks/docker.ts 262 lines
1// 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}
262
hooks/pixels.ts 104 lines
1/**
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}
104
hooks/text.ts 80 lines
1// 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}
80
types/index.d.ts 43 lines
1/** 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