SLOPSHOPPER

observe

An /observe pane with a tab per provider: Docker and Modal containers with each one's live logs and GPU, CPU and RAM use (and Modal's cost), and Render and…

newpanecommandtoastprocessnetwork
v0.2.0MITupdated 2026-10-05Hula-Hoop-AI/supermods/plugins/observe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · observe
│ ┃ Observe ✕ › fix the failing auth test and add an audit log call │ ┃ [ Docker ] [ Modal ] [ Render ] [ Vercel ] │ ┃ 0 containers running [ Refresh ] ⏺ Read(src/auth.ts) │ ┃ Checking… ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /observe │ ⎿ observe: Observe pane opened on Docker. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Observe
[ Docker ] [ Modal ] [ Render ] [ Vercel ] 0 containers running [ Refresh ] Checking…
Pane · observe-logs
Press logs on a container in /observe.
Pane · observe-metrics
Press metrics on a container in /observe.
README

observe

What it does

Adds /observe: one pane with a tab per provider, for what is running and deploying outside the session. Every tab draws the same layout: the tabs, a summary line with its buttons, one row per item, and a footer.

[ Docker ] [ Modal ] [ Render ] [ Vercel ]
3 deploys in 2 apps, 1 building · on feature-x               [ Refresh ]
https://web.vercel.app                                      [ copy ]
› building web  preview feature-x abcdef1 alice 5m
      Add login
  ready    web  prod main 1234567 bob 2h
      Release
docs
  ready    docs prod main 89abcde carol 1h
updated 9:03:23 PM · every 10s

/observe docker, /observe modal, /observe render or /observe vercel opens the pane on that tab. /observe alone reopens the tab you last used. Pressing a tab switches provider.

TabRowsExtras
Dockerdocker ps: name, image, status, portsa copy button per container name; logs and metrics buttons per container
Modalone row per running container, by app then start time: app, the end of the container id, who launched the app, the app's cost over the last 7 days, ageMine only / All runs; logs and metrics buttons per container
Renderrecent deploys: state, service, environment, branch, commit, trigger, age, commit message, linkcurrent git branch marked ›
Vercelrecent deployments grouped by app: a bold headline per app, its production URL (a custom domain first, else its shortest *.vercel.app one; the project name when it has none), then its deployments newest first: state, project, environment, branch, commit, creator, age, commit message (no per-deploy link: the headline carries the app's URL). Apps are ordered by their newest deploymenta copy button per app URL; current git branch marked ›

Deploy states are normalized to building (yellow), ready (green), error (red) and canceled (dim). A Docker row's dot is green for a running container and dim for a stopped one. A Modal row's dot is green once the container runs and yellow while it is pending.

Only the tab on screen is refreshed: Docker every 2 seconds, Modal every 20 seconds (costs every 5 minutes), Render and Vercel every 60 seconds, or every 10 while a deploy is building. Closing the pane stops polling. A missing CLI or daemon shows as one dim line; a missing or rejected credential, a rate limit or a network error shows as one red line.

Container logs and metrics (Modal and Docker)

Modal bills per app, so every container of an app shows the same cost. Each Modal and Docker row has two buttons, each opening a pane of its own next to /observe:

  • logs opens "Modal logs" or "Docker logs" with the container's latest log entries (the last 100, as many as fit; Docker's stdout and stderr together, in time order).
  • metrics opens "Modal metrics" or "Docker metrics" with the container's GPU, CPU and RAM use:
GPU0 NVIDIA H100 80GB HBM3 · 512/700 W · 64°C
GPU0 util █████████████████░░░  87%
GPU0 mem  ███████████████░░░░░  77%  61.3 / 79.6 GiB
CPU       ███░░░░░░░░░░░░░░░░░  14%  2.3 / 16 cores
RAM       ░░░░░░░░░░░░░░░░░░░░   2%  18.5 / 1024 GiB host
updated 9:04:10 PM · every 5s

There is one logs pane and one metrics pane: pressing the button on another container points the open pane at it. Each meter shows a percentage and its figures; the bars share one color and narrow (down to 8 cells) in a narrow pane. A container without a GPU shows a dim no GPU. CPU is the cores in use between two samples, against the container's CPU quota (read once from its cgroup), so it reads measuring… on the first one; without a quota it is the cores alone, with no bar. RAM is measured against the container's cgroup memory limit when that is below the machine's memory, else against the machine's memory, marked host (see Limitations).

On Docker, CPU and RAM come from docker stats, measured against the container's own limits (--cpus or a CPU quota, and --memory, from docker inspect). A container without a limit is measured against what the Docker host has (docker info: CPUs and memory), marked host:

CPU   ███░░░░░░░░░░░░░░░░░  15%  1.2 / 8 cores host
RAM   ██████░░░░░░░░░░░░░░  30%  2.3 / 7.7 GiB
NET   1.63kB in / 512B out
BLOCK 1.93MB read / 0B written

Network and block I/O are the totals docker stats prints. A Docker container with GPUs assigned (--gpus) also gets the GPU meters, from nvidia-smi run in it; others have no GPU section.

The logs and metrics panes are shared by both tabs, one of each. Both panes refresh every detail_refresh_seconds (5 by default) while open, one call at a time (a tick is skipped while the previous call still runs), and stop when closed. An error, such as a container that has stopped, shows as one red line in the pane.

Install

/plugin marketplace add Hula-Hoop-AI/supermods
/plugin install {m}@supermods

Each tab needs its provider reachable; a tab whose provider is not set up says so and the others keep working.

TabNeeds
Dockerthe docker CLI on your PATH and a running daemon
ModalmacOS or Linux, and the modal CLI on your PATH, logged in (modal token new)
RenderRENDER_API_KEY in the environment Claude Code starts in
VercelVERCEL_TOKEN in the environment Claude Code starts in; a .vercel/project.json (from vercel link) in the session's directory or a parent picks the project and team

Configuration

Option / variableDefaultEffect
providersall fourWhich tabs the pane has, in this order.
docker_contextemptyThe docker context to list. Empty follows DOCKER_HOST, then your current context.
docker_allfalseAlso list stopped containers (docker ps --all).
docker_refresh_seconds2Docker refresh interval (1-3600).
modal_environmentemptyThe Modal environment to list. Empty follows MODAL_ENVIRONMENT, then your modal profile, then the workspace default.
detail_refresh_seconds5How often a Modal or Docker container's logs and metrics panes refresh while open (2-3600).
render_servicesemptyComma-separated Render service names to show. Empty shows every service, up to the cap.
render_max_services10Most Render services to list deploys for (1-50). A full refresh makes one request per service.
vercel_teamemptyTeam id (team_...) or slug. Empty uses the team in .vercel/project.json, else your personal account.
vercel_projectemptyProject id or name. Empty uses the linked project, else every project in the scope.
deploy_max_rows15How many deploys the Render and Vercel tabs list, newest first (1-100).
deploy_refresh_seconds60Render and Vercel refresh interval while nothing is building (15-3600).
deploy_building_refresh_seconds10Render and Vercel refresh interval while a deploy is building (5-600).
notify_on_finishfalseWhile their tab is not on screen (the pane closed, or another tab showing), keep watching current-branch deploys that were building and toast when each is ready or fails.
MODAL_ENVIRONMENT (env var)unsetUsed when modal_environment is empty.
RENDER_API_KEY (env var)unsetRender API key.
VERCEL_TOKEN (env var)unsetVercel API token.

Every option appears as a row in /config. Changes apply immediately.

What it touches

From claude plugin validate --strict:

  • Events: session.start (registers /observe; resumes polling after a reload), command.run for /observe, ui.close and ui.render for its own panes (observe, observe-logs, observe-metrics).
  • Processes: $.process.run, only for the tab on screen, and the Modal logs and metrics panes while they are open:
  • docker [--context <ctx>] ps [--all] --format '{{json .}}'
  • docker [--context <ctx>] logs --tail 100 --timestamps <id>: the logs pane, only while it is open.
  • docker [--context <ctx>] stats --no-stream --format '{{json .}}' <id>, and once per container docker inspect <id> and docker info --format '{{json .}}': the metrics pane, only while it is open. For a container with GPUs assigned, also docker [--context <ctx>] exec <id> nvidia-smi, which runs inside that container; nothing else is run in a Docker container.
  • sh -c <launcher>: finds the Python that runs your modal CLI and runs a short helper on stdin, which lists running containers, each app's creator and your username with your existing Modal credentials. It uses the Modal client's private API, the only place app creators are exposed.
  • modal container list --json [--env <env>]: the fallback when the helper fails.
  • modal billing report --start <7 days ago> --resolution h --json
  • modal container logs <container_id>: the logs pane, only while it is open.
  • modal container exec --no-pty <container_id> nvidia-smi and modal container exec --no-pty <container_id> cat <files>: the metrics pane, only while it is open. These run commands inside your container: nvidia-smi, and cat over /sys/fs/cgroup/cpu.stat, memory.current, memory.max (cgroup v2), else /sys/fs/cgroup/cpuacct/cpuacct.usage, memory/memory.usage_in_bytes, memory/memory.limit_in_bytes (cgroup v1), else /proc/loadavg and /proc/meminfo; and once per container, for the meters' limits, cat over /sys/fs/cgroup/cpu.max (v2) or /sys/fs/cgroup/cpu/cpu.cfs_quota_us and cpu.cfs_period_us (v1), with /proc/meminfo. Nothing else is ever run in a container, and nothing is written there.
  • Network: $.http.fetch, read-only GETs with your credential as a bearer header, to https://api.render.com/v1/services…, https://api.vercel.com/v7/deployments… and https://api.vercel.com/v9/projects/<project>… (one per app shown, to read its production domain: on opening the pane, on Refresh, and at most hourly otherwise).
  • Files (read only): $.fs.read of .git/HEAD (or a worktree's .git file and the HEAD it points to) and .vercel/project.json, in the session's directory and its parents. No git process runs.
  • Environment: reads MODAL_ENVIRONMENT, RENDER_API_KEY, VERCEL_TOKEN; writes nothing.
  • Other calls: $.clock, $.session.cwd, $.ui.open/panes/copy/toast, $.command.register.
  • State: its own session state (observe.tab, snapshots, toggles, watching, logs, metrics). It writes no files and uses no store.

Each credential goes only to its own provider. The mod never draws or logs one.

Limitations

  • Modal needs macOS or Linux. Creators come from a private client API that may change; the tab then falls back to the plain CLI's list, without creators or the Mine only button.
  • Modal costs are billed full hours over the last 7 days, not live spend, per app.
  • The RAM bar is against the host on Modal. Modal's sandboxes set the cgroup memory limit to the machine's memory (equal to MemTotal in /proc/meminfo), and the memory a function requested is not readable for a running container through Modal's client API. So the RAM bar reads near 0% against a figure marked host; the used figure is the container's own.
  • Modal metrics are sampled, not streamed. Each refresh starts two short modal container exec calls (about a second each), so CPU cores are averaged over the refresh interval with some timing jitter. Where only /proc is readable, CPU is the load average, which Modal's sandboxes report as 0. An exec now and then answers with nothing; the pane then keeps the last figures and asks again on the next refresh.
  • Modal logs show the last 100 entries, as modal container logs fetches them; long lines are cut at the pane's edge.
  • The logs and metrics panes open next to /observe: on a surface that shows panes as tabs, a new one may open as a tab you have to select, and it does not take focus.
  • Render and Vercel show one page: the newest deploy_max_rows deploys (on Vercel, across all apps; an app's headline does not count). On Render a deploy's branch is its service's configured branch.
  • A Vercel app's URL needs the token to read the project. When it cannot (a token scoped away from it, a network error), the headline shows the project name and the lookup is retried on Refresh or after an hour.
  • The finish toast only covers builds a tab saw building. A deploy that starts while its tab is off screen is not watched.
  • Credentials come from the environment Claude Code started in, so changing one needs a restart.
  • A surface that places no panes gets a message from /observe instead, and nothing polls.
Source 18 files
hooks/register.tsx 291 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { Logs, Metrics, ProviderId, Snapshot, Target } from '../types'
5import type { Io } from './io'
6import { buildProviders, fetchProvider } from './providers'
7import type { Provider } from './providers'
8import { detailTitle, fetchDetailLogs, fetchDetailMetrics } from './providers/details'
9import { logsPane, metricsPane } from './providers/detail-view'
10import { tabPane } from './tab-pane'
11import type { Row } from './tab-pane'
12import { DETAILS, message, num } from './util'
13import type { Detail } from './util'
14
15const PANE = 'observe'
16const COMMAND = 'observe'
17const EMPTY: Snapshot = { rows: [] }
18const DETAIL_REFRESH_S = 5
19// The per-container panes the Modal and Docker tabs share, one of each: another row's button
20// points it at that container.
21const LOGS_PANE = 'observe-logs'
22const METRICS_PANE = 'observe-metrics'
23const DETAIL_PANES: Record<Detail, string> = { logs: LOGS_PANE, metrics: METRICS_PANE }
24
25// '' until a tab is picked: the first provider shows
26const tab = atom({ plugin: 'observe', key: 'tab' } as const, '' as ProviderId | '')
27const snapshots = atom({ plugin: 'observe', key: 'snapshots' } as const, {} as Partial<Record<ProviderId, Snapshot>>)
28const toggles = atom({ plugin: 'observe', key: 'toggles' } as const, {} as Record<string, boolean>)
29const watching = atom({ plugin: 'observe', key: 'watching' } as const, {} as Partial<Record<ProviderId, string[]>>)
30const logs = atom({ plugin: 'observe', key: 'logs' } as const, null as Logs | null)
31const metrics = atom({ plugin: 'observe', key: 'metrics' } as const, null as Metrics | null)
32
33// Replaced by register; a settings change reloads the module.
34let providers: Provider[] = []
35let notify = false
36let detailMs = DETAIL_REFRESH_S * 1000
37const detailTimers: Partial<Record<Detail, Timer>> = {}
38const detailBusy: Record<Detail, boolean> = { logs: false, metrics: false }
39let timer: Timer | undefined
40let pollRound = 0
41const inflight = new Map<ProviderId, Promise<Snapshot>>()
42
43const bind = ($: EngineInterface): Io => ({
44  run: (argv, options) => $.process.run(argv, options),
45  fetch: (url, headers) => $.http.fetch(url, { headers }),
46  env: {
47    MODAL_ENVIRONMENT: () => $.env.get('MODAL_ENVIRONMENT'),
48    RENDER_API_KEY: () => $.env.get('RENDER_API_KEY'),
49    VERCEL_TOKEN: () => $.env.get('VERCEL_TOKEN'),
50  },
51  now: () => $.clock.now(),
52  readFile: path => $.fs.read(path),
53  cwd: () => $.session.cwd(),
54})
55
56const findProvider = (id: string) => providers.find(p => p.id === id)
57
58// The tab last shown, or the first one when the settings no longer list it.
59const activeProvider = async ($: EngineInterface) => findProvider(await read($, tab)) ?? providers[0]!
60
61const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
62
63async function doRefresh($: EngineInterface, p: Provider, force: boolean): Promise<Snapshot> {
64  const prev = (await read($, snapshots))[p.id] ?? EMPTY
65  let fetched: Snapshot
66  try {
67    fetched = await fetchProvider(bind($), p, prev, force)
68  } catch (err) {
69    fetched = { rows: [], error: message(err) }
70  }
71  const next = { ...fetched, checkedAt: await $.clock.now() }
72  await update($, snapshots, all => ({ ...all, [p.id]: next }))
73  return next
74}
75
76// One refresh per provider at a time: a press during a poll waits for the poll's answer.
77function refresh($: EngineInterface, p: Provider, force: boolean): Promise<Snapshot> {
78  let run = inflight.get(p.id)
79  if (!run) {
80    run = doRefresh($, p, force).finally(() => inflight.delete(p.id))
81    inflight.set(p.id, run)
82  }
83  return run
84}
85
86// Remembers the current branch's busy rows; while their tab is not on screen, toasts the ones
87// that finished.
88async function settleWatch($: EngineInterface, p: Provider, snap: Snapshot, isVisible: boolean) {
89  const before = new Set((await read($, watching))[p.id] ?? [])
90  const mine = snap.rows.filter(r => r.highlight)
91  if (notify && !isVisible) {
92    for (const r of mine) {
93      if (!before.has(r.id)) continue
94      const what = `${r.title}${snap.branch ? ` (${snap.branch})` : ''}`
95      if (r.state === 'ok') $.ui.toast(`${what} is ready on ${p.title}`)
96      if (r.state === 'error') $.ui.toast(`${what} failed on ${p.title}`)
97    }
98  }
99  const now = notify ? mine.filter(r => r.state === 'busy').map(r => r.id) : []
100  await update($, watching, all => ({ ...all, [p.id]: now }))
101}
102
103// The tab on screen, and every provider with a watched row still busy.
104async function pollTargets($: EngineInterface) {
105  const visible = (await isPaneOpen($)) ? await activeProvider($) : undefined
106  const watched = await read($, watching)
107  return { visible, all: providers.filter(p => p === visible || watched[p.id]?.length) }
108}
109
110// Refreshes the targets and schedules the next round; with none left it stops. A newer round
111// (a tab press, Refresh) takes the schedule over from one still running.
112async function poll($: EngineInterface, force = false) {
113  const round = ++pollRound
114  timer?.cancel()
115  timer = undefined
116  const { visible, all } = await pollTargets($)
117  if (!all.length) return
118  const intervals = await Promise.all(
119    all.map(async p => {
120      const snap = await refresh($, p, force && p === visible)
121      await settleWatch($, p, snap, p === visible)
122      return p.intervalMs(snap)
123    }),
124  )
125  if (round !== pollRound || !(await pollTargets($)).all.length) return
126  timer = $.clock.after(Math.min(...intervals), () => void poll($))
127}
128
129// One call at a time per pane: a tick that finds the previous one still running is skipped.
130// The answer is dropped if the pane moved on to another container meanwhile.
131async function refreshDetail($: EngineInterface, kind: Detail) {
132  if (detailBusy[kind]) return
133  detailBusy[kind] = true
134  try {
135    if (kind === 'logs') {
136      const cur = await read($, logs)
137      if (!cur) return
138      const next = await fetchDetailLogs(bind($), cur)
139      await update($, logs, now => (now && sameTarget(now, cur) ? next : now))
140    } else {
141      const cur = await read($, metrics)
142      if (!cur) return
143      const next = await fetchDetailMetrics(bind($), cur)
144      await update($, metrics, now => (now && sameTarget(now, cur) ? next : now))
145    }
146  } finally {
147    detailBusy[kind] = false
148  }
149}
150
151function stopDetail(kind: Detail) {
152  detailTimers[kind]?.cancel()
153  delete detailTimers[kind]
154}
155
156// Refreshes while the pane is open. `ui.close` stops it at once; the pane check on each tick
157// also stops it should a close go unheard (e.g. across a reload).
158function startDetail($: EngineInterface, kind: Detail) {
159  stopDetail(kind)
160  void refreshDetail($, kind)
161  detailTimers[kind] = $.clock.every(detailMs, async () => {
162    if ((await $.ui.panes()).some(p => p.id === DETAIL_PANES[kind])) await refreshDetail($, kind)
163    else stopDetail(kind)
164  })
165}
166
167const sameTarget = (a: Target, b: Target) => a.source === b.source && a.container_id === b.container_id
168
169async function openDetail($: EngineInterface, kind: Detail, target: Target) {
170  if (kind === 'logs') await update($, logs, () => ({ ...target, lines: [] }))
171  else await update($, metrics, () => target)
172  const title = detailTitle(kind, target)
173  const opened = await $.ui.open({ id: DETAIL_PANES[kind], title })
174  if (!opened.isPlaced) $.ui.toast(notPlaced(title, opened.reason))
175  startDetail($, kind)
176}
177
178// The container a Modal or Docker row stands for; undefined on the other tabs.
179function targetOf(p: Provider, row: Row): Target | undefined {
180  if (p.id === 'modal') return { source: 'modal', container_id: row.id, name: row.title }
181  if (p.id === 'docker') return { source: 'docker', container_id: row.id, name: row.title, context: p.config.context || undefined }
182  return undefined
183}
184
185async function copy($: EngineInterface, text: string, surface: RenderSurface) {
186  const r = await $.ui.copy({ text, surface })
187  $.ui.toast(r.isCopied ? `Copied ${text}` : `Could not copy (${r.reason})`)
188}
189
190function notPlaced(what: string, reason: string) {
191  return `${what} is open, but this surface is not showing it: ${reason}`
192}
193
194export const register: Register = (on, options) => {
195  providers = buildProviders(options)
196  notify = options.notify_on_finish === true
197  detailMs = num(options.detail_refresh_seconds, DETAIL_REFRESH_S, 2, 3600) * 1000
198  const ids = providers.map(p => p.id)
199
200  on('session.start', async ($, e, next) => {
201    await $.command.register({
202      name: COMMAND,
203      description: `Watch ${providers.map(p => p.title).join(', ')} in a pane`,
204      argumentHint: `[${ids.join('|')}]`,
205    })
206    // A reload (a settings change, a new version) drops the old timers but keeps the panes
207    // open, or a watched build pending: pick polling back up.
208    void poll($)
209    const open = await $.ui.panes()
210    for (const kind of DETAILS) if (open.some(p => p.id === DETAIL_PANES[kind])) startDetail($, kind)
211    return next(e)
212  })
213
214  on('command.run', { command: COMMAND }, async ($, e) => {
215    const wanted = e.args.trim().toLowerCase()
216    const chosen = wanted ? findProvider(wanted) : await activeProvider($)
217    if (!chosen) return { text: `No provider named "${wanted}". Use one of: ${ids.join(', ')}.` }
218    await update($, tab, () => chosen.id)
219    const opened = await $.ui.open({ id: PANE, title: 'Observe' })
220    void poll($, true)
221    return {
222      text: opened.isPlaced ? `Observe pane opened on ${chosen.title}.` : notPlaced('The Observe pane', opened.reason),
223    }
224  })
225
226  on('ui.close', { id: PANE }, async ($, e, next) => {
227    const closed = await next(e)
228    void poll($)
229    return closed
230  })
231
232  on('ui.close', { id: LOGS_PANE }, async ($, e, next) => {
233    stopDetail('logs')
234    return next(e)
235  })
236
237  on('ui.close', { id: METRICS_PANE }, async ($, e, next) => {
238    stopDetail('metrics')
239    return next(e)
240  })
241
242  on('ui.render', { component: 'Pane', requestId: LOGS_PANE }, async ($, e) =>
243    logsPane($.ui.resolve(e), e.props.scroll.bodyRows, await read($, logs), detailMs / 1000),
244  )
245
246  on('ui.render', { component: 'Pane', requestId: METRICS_PANE }, async ($, e) =>
247    metricsPane($.ui.resolve(e), await read($, metrics), detailMs / 1000, e.props.bodyColumns),
248  )
249
250  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
251    const p = await activeProvider($)
252    const snap = (await read($, snapshots))[p.id] ?? EMPTY
253    const flags = await read($, toggles)
254    const isOn = (key: string) => flags[`${p.id}.${key}`] === true
255    const view = p.view(snap, isOn)
256
257    return tabPane($.ui.resolve(e), e.viewport, {
258      tabs: providers.map(({ id, title }) => ({ id, title })),
259      activeTab: p.id,
260      onTab: id => {
261        const picked = findProvider(id)
262        if (picked) void update($, tab, () => picked.id).then(() => poll($))
263      },
264      summary: view.summary,
265      context: snap.context,
266      actions: [
267        ...(p.toggles?.(snap) ?? []).map(t => ({
268          key: t.key,
269          label: isOn(t.key) ? t.on : t.off,
270          isActive: isOn(t.key),
271          onPress: () => void update($, toggles, all => ({ ...all, [`${p.id}.${t.key}`]: !isOn(t.key) })),
272        })),
273        { key: 'refresh', label: 'Refresh', onPress: () => void poll($, true) },
274      ],
275      error: snap.error,
276      notes: snap.notes,
277      checkedAt: snap.checkedAt,
278      empty: snap.unavailable ?? view.empty,
279      rows: view.rows,
280      onCopy: (text, surface) => void copy($, text, surface),
281      columns: e.props.bodyColumns,
282      onRowAction: (row, key) => {
283        const kind = DETAILS.find(d => d === key)
284        const target = targetOf(p, row)
285        if (kind && target) void openDetail($, kind, target)
286      },
287      footer: [`every ${Math.round(p.intervalMs(snap) / 1000)}s`, p.footnote].filter(Boolean).join(' · '),
288    })
289  })
290}
291
hooks/io.ts 16 lines
1import type { ProcessRunResult } from 'claude-code'
2
3// What a provider may reach, as plain functions. register.tsx binds them to `$`: the validator
4// follows `$` only inside the hooks module's own file, never across an import.
5// The validator lists the variables a mod reads, so each is read by its literal name.
6export type EnvName = 'MODAL_ENVIRONMENT' | 'RENDER_API_KEY' | 'VERCEL_TOKEN'
7
8export type Io = {
9  run: (argv: string[], options: { stdin?: string; timeoutMs: number }) => Promise<ProcessRunResult>
10  fetch: (url: string, headers: Record<string, string>) => Promise<{ ok: boolean; status: number; text: string }>
11  env: Record<EnvName, () => Promise<string | undefined>>
12  now: () => Promise<number>
13  readFile: (path: string) => Promise<string>
14  cwd: () => Promise<string>
15}
16
hooks/providers/index.ts 51 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { Io } from '../io'
4import type { ObserveRow, ProviderId, Snapshot } from '../../types'
5import { list } from '../util'
6import { docker, fetchDocker } from './docker'
7import type { DockerConfig } from './docker'
8import { fetchModal, modal } from './modal'
9import type { ModalConfig } from './modal'
10import { fetchRender, render } from './render'
11import type { RenderConfig } from './render'
12import { fetchVercel, vercel } from './vercel'
13import type { VercelConfig } from './vercel'
14
15export type Toggle = { key: string; on: string; off: string }
16
17export type View = { rows: ObserveRow[]; summary: string; empty: string }
18
19type Configs = { docker: DockerConfig; modal: ModalConfig; render: RenderConfig; vercel: VercelConfig }
20
21// A provider shapes data; the pane, the polling and the toasts are shared.
22export type ProviderOf<I extends ProviderId> = {
23  id: I
24  title: string
25  config: Configs[I]
26  intervalMs: (snap: Snapshot) => number
27  view: (snap: Snapshot, isOn: (toggle: string) => boolean) => View
28  toggles?: (snap: Snapshot) => Toggle[]
29  footnote?: string
30}
31export type Provider = { [I in ProviderId]: ProviderOf<I> }[ProviderId]
32
33// `force` is a refresh the person asked for: skip whatever the provider would reuse.
34export function fetchProvider(io: Io, p: Provider, prev: Snapshot, force: boolean): Promise<Snapshot> {
35  if (p.id === 'docker') return fetchDocker(io, p.config)
36  if (p.id === 'modal') return fetchModal(io, p.config, prev, force)
37  if (p.id === 'render') return fetchRender(io, p.config, prev, force)
38  return fetchVercel(io, p.config, prev, force)
39}
40
41const FACTORIES: Record<ProviderId, (options: PluginOptions) => Provider> = { docker, modal, render, vercel }
42export const PROVIDER_IDS = Object.keys(FACTORIES) as ProviderId[]
43
44const isProviderId = (s: string): s is ProviderId => s in FACTORIES
45
46// The tabs, in the order the `providers` setting names them; every provider when it names none.
47export function buildProviders(options: PluginOptions): Provider[] {
48  const wanted = [...new Set(list(options.providers).map(s => s.toLowerCase()).filter(isProviderId))]
49  return (wanted.length ? wanted : PROVIDER_IDS).map(id => FACTORIES[id](options))
50}
51
hooks/providers/details.ts 20 lines
1// The logs and metrics panes' reads, by the tab the container came from.
2import type { Io } from '../io'
3import type { Logs, Metrics, Target } from '../../types'
4import type { Detail } from '../util'
5import { fetchDockerLogs, fetchDockerMetrics } from './docker-detail'
6import { shortId } from './modal'
7import { fetchModalLogs, fetchModalMetrics } from './modal-detail'
8
9export const fetchDetailLogs = (io: Io, prev: Logs) =>
10  prev.source === 'docker' ? fetchDockerLogs(io, prev) : fetchModalLogs(io, prev)
11
12export const fetchDetailMetrics = (io: Io, prev: Metrics) =>
13  prev.source === 'docker' ? fetchDockerMetrics(io, prev) : fetchModalMetrics(io, prev)
14
15// e.g. "Modal logs · train-llm …AAA111", "Docker metrics · webapp-web-1" (a Docker name is unique).
16export function detailTitle(kind: Detail, t: Target) {
17  const what = `${t.source === 'docker' ? 'Docker' : 'Modal'} ${kind}`
18  return `${what} · ${t.name}${t.source === 'modal' ? ` ${shortId(t.container_id)}` : ''}`
19}
20
hooks/providers/detail-view.tsx 136 lines
1// The logs and metrics panes the Modal and Docker tabs open. `ui` is `$.ui.resolve(e)`, as for tabPane.
2import type { ElementTable } from 'claude-code'
3
4import type { Logs, Metrics } from '../../types'
5
6const CHROME_ROWS = 3 // the error, the footer and a spare
7// One accent for every meter: the label says what it measures, the color does not.
8const ACCENT = 'cyan'
9const FILLED = '█'
10const TRACK = '░'
11const BAR_MAX = 20
12const BAR_MIN = 8
13
14const updated = (at: number, everyS: number) => `updated ${new Date(at).toLocaleTimeString()} · every ${everyS}s`
15
16// The newest lines that fit the pane's body (`bodyRows`), oldest first.
17export function logsPane(ui: ElementTable, bodyRows: number, l: Logs | null, everyS: number) {
18  const { Box, Text } = ui
19  if (!l) return <Text dimColor>Press logs on a container in /observe.</Text>
20  const room = Math.max(1, bodyRows - CHROME_ROWS)
21  return (
22    <Box flexDirection="column">
23      {l.error && <Text color="red">{l.error}</Text>}
24      {l.checkedAt === undefined && <Text dimColor>Loading…</Text>}
25      {l.checkedAt !== undefined && !l.error && l.lines.length === 0 && <Text dimColor>No logs yet.</Text>}
26      {l.lines.slice(-room).map(line => (
27        <Text wrap="truncate-end">{line}</Text>
28      ))}
29      {l.checkedAt !== undefined && (
30        <Text dimColor>
31          {updated(l.checkedAt, everyS)} · last {l.lines.length} entries
32        </Text>
33      )}
34    </Box>
35  )
36}
37
38// A bar `width` cells wide, filled to `fraction` (clamped to 0..1; not a number reads as 0).
39export function meter(fraction: number, width: number): { filled: string; track: string } {
40  const cells = Math.max(0, Math.floor(width))
41  const f = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0
42  const n = Math.round(f * cells)
43  return { filled: FILLED.repeat(n), track: TRACK.repeat(cells - n) }
44}
45
46// GiB with one decimal, whole above 100 (a host's 1024 GiB, not 1024.0).
47export const gib = (bytes: number) => {
48  const g = bytes / 2 ** 30
49  return g >= 100 ? g.toFixed(0) : g.toFixed(1)
50}
51const pct = (f: number) => `${Math.round(Math.min(1, Math.max(0, f)) * 100)}%`.padStart(4)
52
53// A labeled line: a meter and its figures, or the figures alone when there is no denominator.
54type Line = { label: string; fraction?: number; text: string }
55
56export function metricLines(m: Metrics): Line[] {
57  const lines: Line[] = []
58  for (const g of m.gpus ?? []) {
59    const name = `GPU${g.index}`
60    if (g.util !== undefined) lines.push({ label: `${name} util`, fraction: g.util / 100, text: pct(g.util / 100) })
61    if (g.memUsedMiB !== undefined && g.memTotalMiB) {
62      const f = g.memUsedMiB / g.memTotalMiB
63      lines.push({ label: `${name} mem`, fraction: f, text: `${pct(f)}  ${gib(g.memUsedMiB * 2 ** 20)} / ${gib(g.memTotalMiB * 2 ** 20)} GiB` })
64    }
65  }
66  if (m.memUsed === undefined) return lines
67  const cpus = m.limits?.cpus
68  if (m.cores !== undefined && cpus) {
69    const f = m.cores / cpus
70    const host = m.limits?.cpusHost ? ' host' : ''
71    lines.push({ label: 'CPU', fraction: f, text: `${pct(f)}  ${m.cores.toFixed(1)} / ${+cpus.toFixed(2)} cores${host}` })
72  } else if (m.cores !== undefined) lines.push({ label: 'CPU', text: `${m.cores.toFixed(2)} cores` })
73  else if (m.load !== undefined) lines.push({ label: 'CPU', text: `load ${m.load.toFixed(2)}` })
74  else lines.push({ label: 'CPU', text: 'measuring…' })
75  // A limit counts only when it is below the machine's memory (on Modal the cgroup's is the host's).
76  const host = m.limits?.memTotal
77  // A Docker limit is only read when the container sets one (HostConfig.Memory), so it is real.
78  const isReal = m.memLimit !== undefined && (m.source === 'docker' || (host !== undefined && m.memLimit < host))
79  const limit = isReal ? m.memLimit : (host ?? m.memLimit)
80  if (limit) {
81    const f = m.memUsed / limit
82    // Under 1 GiB, MiB: "128 / 512 MiB" reads better than "0.1 / 0.5 GiB".
83    const [unit, scale] = limit < 2 ** 30 ? ['MiB', 2 ** 20] : ['GiB', 2 ** 30]
84    const fmt = (b: number) => (unit === 'MiB' ? (b / scale).toFixed(0) : gib(b))
85    lines.push({ label: 'RAM', fraction: f, text: `${pct(f)}  ${fmt(m.memUsed)} / ${fmt(limit)} ${unit}${isReal ? '' : ' host'}` })
86  } else lines.push({ label: 'RAM', text: `${gib(m.memUsed)} GiB` })
87  if (m.io) lines.push({ label: 'NET', text: m.io.net }, { label: 'BLOCK', text: m.io.block })
88  return lines
89}
90
91// `columns` is the pane's body width: the bars narrow (to BAR_MIN) before the figures are cut.
92export function metricsPane(ui: ElementTable, m: Metrics | null, everyS: number, columns: number) {
93  const { Box, Text } = ui
94  if (!m) return <Text dimColor>Press metrics on a container in /observe.</Text>
95  const lines = metricLines(m)
96  const labelWidth = Math.max(0, ...lines.map(l => l.label.length))
97  const textWidth = Math.max(0, ...lines.map(l => l.text.length))
98  const barWidth = Math.max(BAR_MIN, Math.min(BAR_MAX, columns - labelWidth - textWidth - 3))
99  const line = (l: Line) => {
100    if (l.fraction === undefined) {
101      return (
102        <Text wrap="truncate-end">
103          {l.label.padEnd(labelWidth)} {l.text}
104        </Text>
105      )
106    }
107    const bar = meter(l.fraction, barWidth)
108    return (
109      <Text wrap="truncate-end">
110        {l.label.padEnd(labelWidth)} <Text color={ACCENT}>{bar.filled}</Text>
111        <Text dimColor>{bar.track}</Text> {l.text}
112      </Text>
113    )
114  }
115  return (
116    <Box flexDirection="column">
117      {m.error && <Text color="red">{m.error}</Text>}
118      {m.checkedAt === undefined && <Text dimColor>Loading…</Text>}
119      {m.gpuNote && <Text dimColor>{m.gpuNote}</Text>}
120      {m.gpus?.map(g => (
121        <Box flexDirection="column">
122          <Text wrap="truncate-end">
123            <Text bold>GPU{g.index}</Text> {g.name}
124            {g.powerW !== undefined && ` · ${g.powerW}/${g.powerCapW ?? '?'} W`}
125            {g.tempC !== undefined && ` · ${g.tempC}°C`}
126          </Text>
127          {lines.filter(l => l.label.startsWith(`GPU${g.index} `)).map(line)}
128        </Box>
129      ))}
130      {m.cpuMemNote && <Text dimColor>CPU/RAM: {m.cpuMemNote}</Text>}
131      {lines.filter(l => !l.label.startsWith('GPU')).map(line)}
132      {m.checkedAt !== undefined && <Text dimColor>{updated(m.checkedAt, everyS)}</Text>}
133    </Box>
134  )
135}
136
hooks/tab-pane.tsx 192 lines
1// The pane layout every tabbed mod draws: tabs, a summary line with actions, rows, a footer.
2// Canonical copy: shared/tab-pane.tsx. Edit it there and run shared/sync.sh; a plugin
3// installs alone, so each mod carries its own copy.
4import type { ElementTable, RenderSurface, RenderViewport } from 'claude-code'
5
6export type RowState = 'ok' | 'busy' | 'error' | 'idle'
7
8export type Tag = { text: string; color?: string; dimColor?: boolean; bold?: boolean }
9
10export type Row = {
11  id: string
12  state?: RowState
13  label?: string // the state in words; a dot is drawn without one
14  title: string
15  tags?: Tag[]
16  age?: string
17  sub?: string // a second, dim line
18  link?: string
19  copyText?: string
20  highlight?: boolean
21  actions?: { key: string; label: string }[] // buttons beside the row; a press calls onRowAction
22  heading?: boolean // a group's headline over the rows after it: its title in bold (a link when `link`), its copy button; nothing else
23}
24
25export type Action = { key: string; label: string; isActive?: boolean; onPress: () => void }
26
27export type PaneModel = {
28  tabs: { id: string; title: string }[]
29  activeTab: string
30  onTab: (id: string) => void
31  summary: string
32  context?: string
33  actions?: Action[]
34  error?: string
35  notes?: string[]
36  checkedAt?: number // undefined until the first answer
37  empty: string
38  rows: Row[]
39  footer?: string
40  onCopy: (text: string, surface: RenderSurface) => void
41  onRowAction?: (row: Row, key: string) => void
42  columns?: number // the pane's body width (`e.props.bodyColumns`); titles shrink to keep the rest of a row in view
43}
44
45const STATE_COLORS: Record<RowState, string | undefined> = {
46  ok: 'green',
47  busy: 'yellow',
48  error: 'red',
49  idle: undefined, // drawn dim
50}
51const DOT = '●'
52const MAX_TITLE_PAD = 32
53const MAX_SUB = 72
54const CHROME_ROWS = 5 // tabs, summary, footer, the "more" line and a spare
55const MIN_TITLE = 8
56const BUTTON_CHROME = 5 // "[ " and " ]" around a button's label, and the gap before it
57
58const truncate = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
59const widest = (cells: string[], cap: number) => Math.min(cap, Math.max(0, ...cells.map(c => c.length)))
60
61// As many rows as the viewport has lines for; a row with a sub-line takes two.
62export function fitRows(rows: Row[], lines: number): Row[] {
63  const fit: Row[] = []
64  let left = Math.max(1, lines)
65  for (const row of rows) {
66    left -= row.sub ? 2 : 1
67    if (left < 0 && fit.length) break
68    fit.push(row)
69  }
70  if (fit.length > 1 && fit[fit.length - 1]!.heading) fit.pop() // no headline without a row under it
71  return fit
72}
73
74// `ui` is `$.ui.resolve(e)`: the validator follows `$` only inside the hooks module's own file,
75// so the caller resolves the elements and supplies the handlers.
76export function tabPane(ui: ElementTable, viewport: RenderViewport | undefined, m: PaneModel) {
77  const { Box, Text, Button, Link } = ui
78  const notes = m.notes ?? []
79  const shown = fitRows(m.rows, (viewport?.rows ?? 24) - CHROME_ROWS - notes.length - (m.error ? 1 : 0))
80  const hidden = m.rows.slice(shown.length).filter(r => !r.heading).length
81  const items = shown.filter(r => !r.heading) // headlines take no part in the columns
82  const labelWidth = widest(items.map(r => r.label ?? ''), MAX_TITLE_PAD)
83  const marks = items.some(r => r.highlight)
84  // Everything on a row's line but its title.
85  const rest = (r: Row) =>
86    (marks ? 2 : 0) +
87    (r.label === undefined ? 1 : labelWidth) +
88    1 +
89    (r.tags ?? []).reduce((n, t) => n + 1 + t.text.length, 0) +
90    (r.age ? 1 + r.age.length : 0) +
91    (r.copyText !== undefined ? 'copy'.length + BUTTON_CHROME : 0) +
92    (r.actions ?? []).reduce((n, a) => n + a.label.length + BUTTON_CHROME, 0)
93  const room = m.columns === undefined ? Infinity : Math.max(MIN_TITLE, m.columns - Math.max(0, ...items.map(rest)))
94  const titleWidth = Math.min(room, widest(items.map(r => r.title), MAX_TITLE_PAD))
95  const title = (r: Row) => (r.title.length > room ? truncate(r.title, room) : r.title).padEnd(titleWidth)
96
97  return (
98    <Box flexDirection="column">
99      {m.tabs.length > 1 && (
100        <Box flexDirection="row" gap={1}>
101          {m.tabs.map(t => (
102            <Button key={`tab:${t.id}`} variant={t.id === m.activeTab ? 'primary' : 'secondary'} onPress={() => m.onTab(t.id)}>
103              {t.title}
104            </Button>
105          ))}
106        </Box>
107      )}
108      <Box flexDirection="row" gap={1}>
109        <Text bold>
110          {m.summary}
111          {m.context && <Text dimColor> · {m.context}</Text>}
112        </Text>
113        {(m.actions ?? []).map(a => (
114          <Button key={a.key} variant={a.isActive ? 'primary' : 'secondary'} onPress={a.onPress}>
115            {a.label}
116          </Button>
117        ))}
118      </Box>
119      {m.error && <Text color="red">{m.error}</Text>}
120      {notes.map(n => (
121        <Text dimColor>{n}</Text>
122      ))}
123      {m.checkedAt === undefined && !m.error && <Text dimColor>Checking…</Text>}
124      {m.checkedAt !== undefined && !m.error && m.rows.length === 0 && <Text dimColor>{m.empty}</Text>}
125      {shown.map(r => {
126        if (r.heading) {
127          return (
128            <Box flexDirection="row" gap={1}>
129              <Text bold wrap="truncate-end">
130                {r.link ? <Link href={r.link}>{r.title}</Link> : r.title}
131              </Text>
132              {r.copyText !== undefined && (
133                <Button key={`copy:${r.id}`} dimColor onPress={press => m.onCopy(r.copyText!, press.surface)}>
134                  copy
135                </Button>
136              )}
137            </Box>
138          )
139        }
140        const color = r.state && STATE_COLORS[r.state]
141        return (
142          <Box flexDirection="column">
143            <Box flexDirection="row" gap={1}>
144              <Text wrap="truncate-end">
145                {marks && <Text color="cyan">{r.highlight ? '› ' : '  '}</Text>}
146                <Text color={color} dimColor={!color} bold={r.state !== 'idle'}>
147                  {r.label === undefined ? DOT : r.label.padEnd(labelWidth)}
148                </Text>{' '}
149                {title(r)}
150                {(r.tags ?? []).map(t => (
151                  <Text color={t.color} dimColor={t.dimColor} bold={t.bold}>
152                    {' '}
153                    {t.text}
154                  </Text>
155                ))}
156                {r.age && <Text dimColor> {r.age}</Text>}
157              </Text>
158              {r.copyText !== undefined && (
159                <Button key={`copy:${r.id}`} dimColor onPress={press => m.onCopy(r.copyText!, press.surface)}>
160                  copy
161                </Button>
162              )}
163              {(r.actions ?? []).map(a => (
164                <Button key={`${a.key}:${r.id}`} dimColor onPress={() => m.onRowAction?.(r, a.key)}>
165                  {a.label}
166                </Button>
167              ))}
168              {r.link && !r.sub && <Link href={r.link} />}
169            </Box>
170            {r.sub && (
171              <Box flexDirection="row" gap={1}>
172                <Text dimColor>
173                  {'    '}
174                  {truncate(r.sub, MAX_SUB)}
175                </Text>
176                {r.link && <Link href={r.link} />}
177              </Box>
178            )}
179          </Box>
180        )
181      })}
182      {hidden > 0 && <Text dimColor>…and {hidden} more</Text>}
183      {m.checkedAt !== undefined && (
184        <Text dimColor>
185          updated {new Date(m.checkedAt).toLocaleTimeString()}
186          {m.footer && <Text> · {m.footer}</Text>}
187        </Text>
188      )}
189    </Box>
190  )
191}
192
hooks/util.ts 39 lines
1import type { PluginOptions } from 'claude-code'
2import type { Target } from '../types'
3
4type Option = PluginOptions[string] | undefined
5
6export const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
7
8export const list = (v: Option) =>
9  (Array.isArray(v) ? v : String(v ?? '').split(','))
10    .map(s => String(s).trim())
11    .filter(Boolean)
12
13export const num = (v: Option, fallback: number, min: number, max = Infinity) => {
14  const n = Number(v)
15  return Number.isFinite(n) && n >= min ? Math.min(max, n) : fallback
16}
17
18export const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
19
20export const lastLine = (stderr: string, exitCode: number) => stderr.trim().split('\n').pop() || `exit ${exitCode}`
21
22export function firstLine(s: string | undefined): string | undefined {
23  const line = s?.split('\n')[0]?.trim()
24  return line || undefined
25}
26
27export function age(ms: number): string {
28  const mins = Math.max(0, Math.floor(ms / 60_000))
29  if (mins < 60) return `${mins}m`
30  if (mins < 48 * 60) return `${Math.floor(mins / 60)}h`
31  return `${Math.floor(mins / 1440)}d`
32}
33
34// The two per-container panes the Modal and Docker tabs open from a row's buttons.
35export const DETAILS = ['logs', 'metrics'] as const
36export type Detail = (typeof DETAILS)[number]
37
38export const targetOf = ({ source, container_id, name, context }: Target): Target => ({ source, container_id, name, context })
39
hooks/providers/docker.ts 81 lines
1import type { PluginOptions, ProcessRunResult } from 'claude-code'
2
3import type { Io } from '../io'
4import type { RowState } from '../tab-pane'
5import type { ObserveRow, Snapshot } from '../../types'
6import { DETAILS, lastLine, num, plural } from '../util'
7import type { ProviderOf } from './index'
8
9// One `docker ps --format '{{json .}}'` line; only the fields read here.
10type PsLine = { ID: string; Image: string; Status: string; Ports?: string; Names: string; State?: string }
11
12const DEFAULT_REFRESH_S = 2
13const DOCKER_TIMEOUT_MS = 5_000
14const NO_DAEMON = /Cannot connect to the Docker daemon/i
15const DENIED = /permission denied/i
16const NOT_FOUND = /ENOENT/ // the engine says "failed to start: ENOENT: Executable not found in $PATH"
17const ROW_STATES: Record<string, RowState> = { running: 'ok', restarting: 'busy', removing: 'busy', dead: 'error' }
18
19export const dockerArgv = (context: string, all: boolean) => [
20  'docker', ...(context ? ['--context', context] : []), 'ps', ...(all ? ['--all'] : []), '--format', '{{json .}}',
21]
22
23export function parsePs(stdout: string): ObserveRow[] {
24  return stdout.split('\n').filter(l => l.trim()).map(l => {
25    const r = JSON.parse(l) as PsLine
26    return {
27      id: r.ID,
28      state: ROW_STATES[r.State ?? ''] ?? 'idle',
29      title: r.Names,
30      tags: [
31        { text: r.Image },
32        { text: r.Status, dimColor: true },
33        ...(r.Ports ? [{ text: r.Ports, dimColor: true }] : []),
34      ],
35      copyText: r.Names,
36      actions: DETAILS.map(key => ({ key, label: key })),
37    }
38  })
39}
40
41// A missing CLI or a stopped daemon is a normal state on a machine that is not using Docker
42// right now, so it is `unavailable` (one dim line); anything else is an `error` worth fixing.
43export function readDocker(ps: PromiseSettledResult<ProcessRunResult>): Snapshot {
44  if (ps.status === 'rejected') {
45    const why = ps.reason instanceof Error ? ps.reason.message : String(ps.reason)
46    return NOT_FOUND.test(why) ? { rows: [], unavailable: 'Docker is not installed.' } : { rows: [], error: why }
47  }
48  const { exitCode, stdout, stderr } = ps.value
49  if (exitCode !== 0) {
50    if (NO_DAEMON.test(stderr)) return { rows: [], unavailable: 'The Docker daemon is not running.' }
51    if (DENIED.test(stderr)) return { rows: [], error: 'permission denied on the Docker socket' }
52    return { rows: [], error: lastLine(stderr, exitCode) }
53  }
54  return { rows: parsePs(stdout) }
55}
56
57export type DockerConfig = { context: string; all: boolean; refreshMs: number }
58
59export async function fetchDocker(io: Io, cfg: DockerConfig): Promise<Snapshot> {
60  const [ps] = await Promise.allSettled([
61    io.run(dockerArgv(cfg.context, cfg.all), { timeoutMs: DOCKER_TIMEOUT_MS }),
62  ])
63  return { ...readDocker(ps), context: cfg.context || undefined }
64}
65
66export function docker(options: PluginOptions): ProviderOf<'docker'> {
67  const all = options.docker_all === true
68  const refreshMs = num(options.docker_refresh_seconds, DEFAULT_REFRESH_S, 1, 3600) * 1000
69  return {
70    id: 'docker',
71    title: 'Docker',
72    config: { context: String(options.docker_context ?? '').trim(), all, refreshMs },
73    intervalMs: () => refreshMs,
74    view: snap => ({
75      rows: snap.rows,
76      summary: `${plural(snap.rows.length, 'container')}${all ? '' : ' running'}`,
77      empty: all ? 'No containers.' : 'No containers running.',
78    }),
79  }
80}
81
hooks/providers/modal.ts 146 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { Io } from '../io'
4import type { Carry, ObserveRow, Snapshot } from '../../types'
5import { age, DETAILS, lastLine, message, plural } from '../util'
6import type { ProviderOf } from './index'
7import { HELPER_PY, helperArgv } from './modal-helper'
8
9const POLL_MS = 20_000
10const COST_REFRESH_MS = 5 * 60_000 // billing is hourly
11const COST_DAYS = 7
12const MINE = 'mine'
13
14type Container = { container_id: string; started_at: number } // epoch seconds; 0 while pending
15type App = {
16  app_id: string
17  app_name: string
18  created_by?: string // absent when only the plain CLI answered
19  containers: Container[]
20}
21type Listing = { apps: App[]; me?: string; env?: string; error?: string; degraded?: string }
22
23async function runJson(io: Io, argv: string[], stdin?: string) {
24  const { exitCode, stdout, stderr } = await io.run(argv, { stdin, timeoutMs: 120_000 })
25  if (exitCode !== 0) throw new Error(lastLine(stderr, exitCode))
26  return JSON.parse(stdout)
27}
28
29// `modal container list --json` rows, grouped by app; no creators.
30type CliRow = { container_id: string; app_id: string; app_name: string; start_time: string }
31export function groupCliRows(rows: CliRow[]): App[] {
32  const apps = new Map<string, App>()
33  for (const r of rows) {
34    const app = apps.get(r.app_id) ?? { app_id: r.app_id, app_name: r.app_name, containers: [] }
35    // start_time looks like "2026-10-04 12:00:00+03:00", or "Pending"
36    const t = Date.parse(r.start_time.replace(' ', 'T'))
37    app.containers.push({ container_id: r.container_id, started_at: Number.isNaN(t) ? 0 : t / 1000 })
38    apps.set(r.app_id, app)
39  }
40  return [...apps.values()]
41}
42
43async function listContainers(io: Io, env: string): Promise<Listing> {
44  try {
45    const out = await runJson(io, helperArgv(env), HELPER_PY)
46    return { apps: out.apps, me: out.me, env: out.env || undefined }
47  } catch (helperErr) {
48    try {
49      const rows: CliRow[] = await runJson(io, [
50        'modal', 'container', 'list', '--json', ...(env ? ['--env', env] : []),
51      ])
52      return { apps: groupCliRows(rows), env: env || undefined, degraded: message(helperErr) }
53    } catch (cliErr) {
54      return { apps: [], error: message(cliErr) }
55    }
56  }
57}
58
59// Cost per app over the last COST_DAYS, from the public billing report (complete hours only).
60async function fetchCosts(io: Io, now: number): Promise<Carry> {
61  const start = new Date(now - COST_DAYS * 86_400_000).toISOString().slice(0, 13) + ':00:00'
62  try {
63    const rows: { object_id: string; cost: string }[] = await runJson(io, [
64      'modal', 'billing', 'report', '--start', start, '--resolution', 'h', '--json',
65    ])
66    const costs: Record<string, number> = {}
67    for (const r of rows) costs[r.object_id] = (costs[r.object_id] ?? 0) + Number(r.cost)
68    return { costs, costsAt: now }
69  } catch (err) {
70    return { costsAt: now, costError: message(err) }
71  }
72}
73
74function money(dollars: number | undefined): string {
75  if (dollars === undefined) return '$—'
76  return dollars < 0.01 ? '<$0.01' : `$${dollars.toFixed(2)}`
77}
78
79// ULIDs share their leading (time) characters, so the tail tells containers apart.
80export const shortId = (id: string) => `…${id.slice(-6)}`
81
82// One row per container, by app then start time (pending last). Modal bills per app, so each
83// row repeats its app's cost.
84export function containerRows(apps: App[], costs: Record<string, number> | undefined, now: number): ObserveRow[] {
85  return apps
86    .flatMap(a => a.containers.map(c => ({ a, c, name: a.app_name || a.app_id })))
87    .sort((x, y) => x.name.localeCompare(y.name) || (x.c.started_at || Infinity) - (y.c.started_at || Infinity))
88    .map(({ a, c, name }) => ({
89      id: c.container_id,
90      state: c.started_at ? 'ok' : 'busy',
91      title: name,
92      tags: [
93        { text: shortId(c.container_id), dimColor: true },
94        ...(a.created_by !== undefined ? [{ text: a.created_by || 'unknown', color: 'cyan' }] : []),
95        ...(costs ? [{ text: money(costs[a.app_id]), color: 'green' }] : []),
96      ],
97      age: c.started_at ? age(now - c.started_at * 1000) : 'pending',
98      owner: a.created_by,
99      actions: DETAILS.map(key => ({ key, label: key })),
100    }))
101}
102
103export type ModalConfig = { environment: string }
104
105export async function fetchModal(io: Io, cfg: ModalConfig, prev: Snapshot, force: boolean): Promise<Snapshot> {
106  // The mod's own setting, then MODAL_ENVIRONMENT; neither means Modal's own default:
107  // the profile's environment, else the workspace's.
108  const env = cfg.environment || (await io.env.MODAL_ENVIRONMENT()) || ''
109  const now = await io.now()
110  const { carry: kept } = prev
111  const fresh = !force && kept?.costsAt !== undefined && now - kept.costsAt < COST_REFRESH_MS ? kept : undefined
112  const [listing, carry] = await Promise.all([listContainers(io, env), fresh ?? fetchCosts(io, now)])
113  return {
114    rows: containerRows(listing.apps, carry.costs, now),
115    me: listing.me,
116    context: listing.env ?? 'default env',
117    error: listing.error,
118    notes: [
119      listing.degraded ? `creators unavailable (${listing.degraded}); showing the CLI's list` : '',
120      carry.costError ? `cost unavailable: ${carry.costError}` : '',
121    ].filter(Boolean),
122    carry,
123  }
124}
125
126export function modal(options: PluginOptions): ProviderOf<'modal'> {
127  return {
128    id: 'modal',
129    title: 'Modal',
130    config: { environment: String(options.modal_environment ?? '').trim() },
131    footnote: `cost: per app, billed full hours, last ${COST_DAYS}d`,
132    intervalMs: () => POLL_MS,
133    // The filter needs creators, which only the helper provides.
134    toggles: snap => (snap.me === undefined ? [] : [{ key: MINE, on: 'Mine only', off: 'All runs' }]),
135    view(snap, isOn) {
136      const onlyMine = isOn(MINE) && snap.me !== undefined
137      const rows = onlyMine ? snap.rows.filter(r => r.owner === snap.me) : snap.rows
138      return {
139        rows,
140        summary: plural(rows.length, 'running container'),
141        empty: onlyMine ? `No runs by ${snap.me}.` : 'Nothing running.',
142      }
143    },
144  }
145}
146
hooks/providers/render.ts 180 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { Io } from '../io'
4import type { Deploy, DeployState, Service, Snapshot } from '../../types'
5import { getJson } from '../http'
6import type { Api } from '../http'
7import { firstLine, list, message, num } from '../util'
8import { detectWorkspace } from '../workspace'
9import type { DeployConfig } from './deploys'
10import { branchContext, deployConfig, deployInterval, deployRows, deployView, newest } from './deploys'
11import type { ProviderOf } from './index'
12
13export const RENDER_API = 'https://api.render.com/v1'
14export const API: Api = {
15  name: 'Render',
16  secret: 'key',
17  envVar: 'RENDER_API_KEY',
18  rateLimitHint: 'set render_services or raise the refresh intervals',
19}
20
21const STATES: Record<string, DeployState> = {
22  created: 'building',
23  queued: 'building',
24  build_in_progress: 'building',
25  update_in_progress: 'building',
26  pre_deploy_in_progress: 'building',
27  live: 'ready',
28  deactivated: 'ready', // a successful deploy a newer one replaced
29  build_failed: 'error',
30  update_failed: 'error',
31  pre_deploy_failed: 'error',
32  canceled: 'canceled',
33}
34
35export const normalizeState = (s: string | undefined): DeployState => STATES[s ?? ''] ?? 'error'
36
37type RenderService = {
38  id: string
39  name: string
40  branch?: string
41  dashboardUrl?: string
42  serviceDetails?: { parentServer?: unknown }
43}
44type RenderDeploy = {
45  id: string
46  status?: string
47  trigger?: string
48  commit?: { id?: string; message?: string }
49  createdAt?: string
50}
51
52export const servicesUrl = () => `${RENDER_API}/services?limit=100`
53export const deploysUrl = (serviceId: string, limit: number) =>
54  `${RENDER_API}/services/${encodeURIComponent(serviceId)}/deploys?limit=${Math.min(limit, 100)}`
55
56export function pickServices(
57  body: { service?: RenderService }[],
58  names: string[],
59  max: number,
60): { services: Service[]; skipped: number } {
61  const all = body.flatMap(r => (r.service ? [r.service] : []))
62  const wanted = names.length ? all.filter(s => names.includes(s.name)) : all
63  return {
64    services: wanted.slice(0, max).map(s => ({
65      id: s.id,
66      name: s.name,
67      branch: s.branch || undefined,
68      dashboardUrl: s.dashboardUrl || undefined,
69      preview: Boolean(s.serviceDetails?.parentServer),
70    })),
71    skipped: Math.max(0, wanted.length - max),
72  }
73}
74
75export function parseDeploys(service: Service, body: { deploy?: RenderDeploy }[]): Deploy[] {
76  return body.flatMap(r => {
77    const d = r.deploy
78    if (!d) return []
79    const created = Date.parse(d.createdAt ?? '')
80    return [{
81      id: d.id,
82      name: service.name,
83      group: service.id,
84      env: service.preview ? 'preview' : 'prod',
85      state: normalizeState(d.status),
86      branch: service.branch,
87      sha: d.commit?.id || undefined,
88      message: firstLine(d.commit?.message),
89      who: d.trigger ? d.trigger.replaceAll('_', ' ') : undefined,
90      createdAt: Number.isNaN(created) ? 0 : created,
91      url: service.dashboardUrl ? `${service.dashboardUrl}/deploys/${d.id}` : undefined,
92    }]
93  })
94}
95
96// Services with a deploy still building: the only ones a fast poll re-fetches.
97export const buildingServiceIds = (deploys: Deploy[]) =>
98  [...new Set(deploys.filter(d => d.state === 'building').flatMap(d => (d.group ? [d.group] : [])))]
99
100type Fetched = { deploys: Deploy[]; services: Service[]; error?: string; notes?: string[]; fullAt?: number }
101
102export type RenderConfig = DeployConfig & { names: string[]; maxServices: number }
103
104// One request per service; a failing service keeps its previous rows (in `failed`) and names the error.
105async function fetchDeploys(io: Io, cfg: RenderConfig, services: Service[], token: string) {
106  const results = await Promise.allSettled(
107    services.map(async s => {
108      const body = await getJson(io, API, deploysUrl(s.id, cfg.maxRows), token)
109      return parseDeploys(s, Array.isArray(body) ? body : [])
110    }),
111  )
112  const failed = new Set(services.filter((_, i) => results[i]!.status === 'rejected').map(s => s.id))
113  const rejected = results.find(r => r.status === 'rejected')
114  return {
115    deploys: results.flatMap(r => (r.status === 'fulfilled' ? r.value : [])),
116    failed,
117    error: rejected ? message(rejected.reason) : undefined,
118  }
119}
120
121// The service list and every service's deploys.
122async function fullFetch(io: Io, cfg: RenderConfig, token: string, now: number): Promise<Fetched> {
123  try {
124    const body = await getJson(io, API, servicesUrl(), token)
125    const { services, skipped } = pickServices(Array.isArray(body) ? body : [], cfg.names, cfg.maxServices)
126    const { deploys, error } = await fetchDeploys(io, cfg, services, token)
127    const notes = [
128      skipped ? `${skipped} more services not shown: set render_services or render_max_services` : '',
129      cfg.names.length && !services.length ? `no service named ${cfg.names.join(', ')}` : '',
130    ].filter(Boolean)
131    return { deploys, services, error, notes, fullAt: now }
132  } catch (err) {
133    return { deploys: [], services: [], error: message(err) }
134  }
135}
136
137// Only the services with a deploy building; every other deploy is kept from the last refresh.
138async function buildingFetch(io: Io, cfg: RenderConfig, token: string, prev: Snapshot): Promise<Fetched> {
139  const before = prev.carry?.deploys ?? []
140  const services = prev.carry?.services ?? []
141  const ids = new Set(buildingServiceIds(before))
142  const { deploys, failed, error } = await fetchDeploys(io, cfg, services.filter(s => ids.has(s.id)), token)
143  const kept = before.filter(d => !d.group || !ids.has(d.group) || failed.has(d.group))
144  return { deploys: [...kept, ...deploys], services, error, notes: prev.notes, fullAt: prev.carry?.fullAt }
145}
146
147export async function fetchRender(io: Io, cfg: RenderConfig, prev: Snapshot, force: boolean): Promise<Snapshot> {
148  const { branch } = await detectWorkspace(io, false)
149  const base = { branch, context: branchContext(branch) }
150  const now = await io.now()
151  const token = await io.env[API.envVar]()
152  if (!token) return { ...base, rows: [], error: `${API.envVar} is not set` }
153  const { services, fullAt } = prev.carry ?? {}
154  const isFull = force || !services?.length || fullAt === undefined || now - fullAt >= cfg.slowMs
155  const got = isFull ? await fullFetch(io, cfg, token, now) : await buildingFetch(io, cfg, token, prev)
156  const deploys = newest(got.deploys, cfg.maxRows)
157  return {
158    ...base,
159    rows: deployRows(deploys, branch, now),
160    error: got.error,
161    notes: got.notes,
162    carry: { services: got.services, deploys, fullAt: got.fullAt },
163  }
164}
165
166export function render(options: PluginOptions): ProviderOf<'render'> {
167  const config: RenderConfig = {
168    ...deployConfig(options),
169    names: list(options.render_services),
170    maxServices: Math.floor(num(options.render_max_services, 10, 1)),
171  }
172  return {
173    id: 'render',
174    title: 'Render',
175    config,
176    intervalMs: snap => deployInterval(snap, config),
177    view: deployView,
178  }
179}
180
hooks/providers/vercel.ts 191 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { Io } from '../io'
4import type { App, Deploy, DeployState, ObserveRow, Snapshot } from '../../types'
5import { getJson } from '../http'
6import type { Api } from '../http'
7import { firstLine, message } from '../util'
8import { detectWorkspace } from '../workspace'
9import type { DeployConfig } from './deploys'
10import { branchContext, deployConfig, deployInterval, deployRows, deployView, newest } from './deploys'
11import type { ProviderOf } from './index'
12
13export const VERCEL_API = 'https://api.vercel.com/v7/deployments'
14export const PROJECTS_API = 'https://api.vercel.com/v9/projects'
15// How long an app's production domain is trusted before it is looked up again.
16const APP_TTL_MS = 60 * 60_000
17export const API: Api = {
18  name: 'Vercel',
19  secret: 'token',
20  envVar: 'VERCEL_TOKEN',
21  rateLimitHint: 'raise the refresh intervals',
22}
23
24export type Scope = { team?: string; project?: string }
25
26const STATES: Record<string, DeployState> = {
27  QUEUED: 'building',
28  INITIALIZING: 'building',
29  BUILDING: 'building',
30  READY: 'ready',
31  ERROR: 'error',
32  BLOCKED: 'error', // held by checks or a seat block: needs someone to act
33  CANCELED: 'canceled',
34  DELETED: 'canceled',
35}
36
37export const normalizeState = (s: string | undefined): DeployState => STATES[s ?? ''] ?? 'error'
38
39type VercelDeployment = {
40  uid: string
41  name: string
42  projectId?: string
43  url?: string | null
44  inspectorUrl?: string | null
45  created: number
46  state?: string
47  readyState?: string
48  target?: string | null
49  customEnvironment?: { slug?: string }
50  creator?: { username?: string; githubLogin?: string; email?: string }
51  meta?: Record<string, string>
52}
53
54function setTeam(q: URLSearchParams, team: string | undefined) {
55  if (team) q.set(team.startsWith('team_') ? 'teamId' : 'slug', team)
56}
57
58export function deploymentsUrl(scope: Scope, limit: number): string {
59  const q = new URLSearchParams({ limit: String(limit) })
60  if (scope.project) q.set('projectId', scope.project) // takes an id or a name
61  setTeam(q, scope.team)
62  return `${VERCEL_API}?${q.toString()}`
63}
64
65// One project, by id or name: the deployments list carries no production domain.
66export function projectUrl(idOrName: string, team: string | undefined): string {
67  const q = new URLSearchParams()
68  setTeam(q, team)
69  const query = q.toString()
70  return `${PROJECTS_API}/${encodeURIComponent(idOrName)}${query ? `?${query}` : ''}`
71}
72
73type VercelProject = { targets?: { production?: { alias?: unknown } } }
74
75// A custom domain first, else the shortest *.vercel.app one (the team-suffixed one is longer).
76export function productionDomain(p: VercelProject): string | undefined {
77  const raw = p.targets?.production?.alias
78  const aliases = (Array.isArray(raw) ? raw : []).filter((a): a is string => typeof a === 'string' && a !== '')
79  return aliases.find(a => !a.endsWith('.vercel.app')) ?? [...aliases].sort((a, b) => a.length - b.length)[0]
80}
81
82// Git metadata is keyed by provider: githubCommitRef, gitlabCommitRef, bitbucketCommitRef, ...
83function gitMeta(meta: Record<string, string> | undefined, suffix: string): string | undefined {
84  if (!meta) return undefined
85  const key = Object.keys(meta).find(k => k.endsWith(suffix))
86  return key ? meta[key] || undefined : undefined
87}
88
89export function parseDeployments(body: { deployments?: VercelDeployment[] }): Deploy[] {
90  return (body.deployments ?? []).map(d => ({
91    id: d.uid,
92    name: d.name,
93    group: d.projectId || d.name,
94    env: d.target === 'production' ? 'prod' : (d.customEnvironment?.slug ?? 'preview'),
95    state: normalizeState(d.readyState ?? d.state),
96    branch: gitMeta(d.meta, 'CommitRef'),
97    sha: gitMeta(d.meta, 'CommitSha'),
98    message: firstLine(gitMeta(d.meta, 'CommitMessage')),
99    who: d.creator?.username ?? d.creator?.githubLogin ?? d.creator?.email,
100    createdAt: d.created,
101    url: d.url ? `https://${d.url}` : (d.inspectorUrl ?? undefined),
102  }))
103}
104
105// Looks up the apps not seen within APP_TTL_MS (all of them when forced), one request each, in
106// parallel. A failed lookup keeps the last domain known, or none: the headline falls back to the
107// project's name, and the app is not asked again until the TTL or a forced refresh.
108export async function lookupApps(
109  io: Io,
110  keys: string[],
111  team: string | undefined,
112  token: string,
113  prev: Record<string, App>,
114  now: number,
115  force: boolean,
116): Promise<Record<string, App>> {
117  const stale = keys.filter(k => force || !prev[k] || now - prev[k].at >= APP_TTL_MS)
118  const found = await Promise.all(
119    stale.map(async (k): Promise<[string, App]> => {
120      try {
121        return [k, { domain: productionDomain(await getJson(io, API, projectUrl(k, team), token)), at: now }]
122      } catch {
123        return [k, { domain: prev[k]?.domain, at: now }]
124      }
125    }),
126  )
127  return { ...prev, ...Object.fromEntries(found) }
128}
129
130// A headline per app (its production domain, else its name) above its deploys, newest first;
131// apps ordered by their newest deploy.
132export function appRows(deploys: Deploy[], apps: Record<string, App>, branch: string | undefined, now: number): ObserveRow[] {
133  const byApp = new Map<string, Deploy[]>()
134  for (const d of newest(deploys, deploys.length)) {
135    const key = d.group ?? d.name
136    byApp.set(key, [...(byApp.get(key) ?? []), d])
137  }
138  return [...byApp].flatMap(([key, list]) => {
139    const domain = apps[key]?.domain
140    const url = domain ? `https://${domain}` : undefined
141    return [
142      { id: `app:${key}`, heading: true, title: domain ?? list[0]!.name, link: url, copyText: url },
143      // The headline carries the app's URL; each deploy's own URL would repeat it row after row.
144      ...deployRows(list, branch, now).map(({ link: _link, ...row }) => row),
145    ]
146  })
147}
148
149export type VercelConfig = DeployConfig & { team: string; project: string }
150
151export async function fetchVercel(io: Io, cfg: VercelConfig, prev: Snapshot, force: boolean): Promise<Snapshot> {
152  const ws = await detectWorkspace(io, true)
153  const base = { branch: ws.branch, context: branchContext(ws.branch) }
154  const now = await io.now()
155  const token = await io.env[API.envVar]()
156  if (!token) return { ...base, rows: [], error: `${API.envVar} is not set` }
157  const isLinked = !cfg.project && ws.linkedProject !== undefined
158  // A personal account's orgId is a user id, which the API takes as no team at all.
159  const linkedTeam = ws.linkedOrg?.startsWith('team_') ? ws.linkedOrg : undefined
160  const team = cfg.team || linkedTeam
161  const url = deploymentsUrl({ team, project: cfg.project || ws.linkedProject }, cfg.maxRows)
162  try {
163    const deploys = newest(parseDeployments(await getJson(io, API, url, token)), cfg.maxRows)
164    const keys = [...new Set(deploys.map(d => d.group ?? d.name))]
165    const apps = await lookupApps(io, keys, team, token, prev.carry?.apps ?? {}, now, force)
166    return {
167      ...base,
168      rows: appRows(deploys, apps, ws.branch, now),
169      notes: isLinked ? ['project from .vercel/project.json'] : undefined,
170      carry: { apps },
171    }
172  } catch (err) {
173    return { ...base, rows: [], error: message(err), carry: prev.carry }
174  }
175}
176
177export function vercel(options: PluginOptions): ProviderOf<'vercel'> {
178  const config: VercelConfig = {
179    ...deployConfig(options),
180    team: String(options.vercel_team ?? '').trim(),
181    project: String(options.vercel_project ?? '').trim(),
182  }
183  return {
184    id: 'vercel',
185    title: 'Vercel',
186    config,
187    intervalMs: snap => deployInterval(snap, config),
188    view: deployView,
189  }
190}
191
hooks/providers/docker-detail.ts 134 lines
1// What the Docker tab's logs and metrics panes read for one container: `docker logs`,
2// `docker stats`, and once per container `docker inspect` and `docker info` for the limits the
3// meters are drawn against. `docker exec <id> nvidia-smi` runs only for a container that has
4// GPUs assigned.
5import type { Io } from '../io'
6import type { Logs, Metrics } from '../../types'
7import { lastLine, message, targetOf } from '../util'
8import { parseNvidiaSmi } from './modal-detail'
9
10const DOCKER_TIMEOUT_MS = 10_000
11const MAX_LINES = 100
12
13const base = (context?: string) => ['docker', ...(context ? ['--context', context] : [])]
14
15const UNITS: Record<string, number> = {
16  B: 1, kB: 1e3, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, KiB: 2 ** 10, MiB: 2 ** 20, GiB: 2 ** 30, TiB: 2 ** 40,
17}
18
19// "2.699MiB", "1.63kB", "0B" to bytes; undefined when it does not read as a size.
20export function parseSize(s: string | undefined): number | undefined {
21  const m = /^([\d.]+)\s*([kKMGT]i?B|B)$/.exec(s?.trim() ?? '')
22  const unit = m && UNITS[m[2]!]
23  return m && unit ? Number(m[1]) * unit : undefined
24}
25
26export type DockerStats = { cores?: number; memUsed?: number; net: string; block: string }
27
28// One `docker stats --no-stream --format '{{json .}}'` line. CPUPerc counts 100% per core.
29export function parseStats(out: string): DockerStats | undefined {
30  const line = out.split('\n').find(l => l.trim().startsWith('{'))
31  if (!line) return undefined
32  const s = JSON.parse(line) as { CPUPerc?: string; MemUsage?: string; NetIO?: string; BlockIO?: string }
33  const pair = (v: string | undefined, a: string, b: string) => {
34    const [x, y] = (v ?? '').split('/').map(p => p.trim())
35    return x && y ? `${x} ${a} / ${y} ${b}` : '—'
36  }
37  const perc = parseFloat(s.CPUPerc ?? '')
38  return {
39    cores: Number.isFinite(perc) ? perc / 100 : undefined,
40    memUsed: parseSize(s.MemUsage?.split('/')[0]),
41    net: pair(s.NetIO, 'in', 'out'),
42    block: pair(s.BlockIO, 'read', 'written'),
43  }
44}
45
46type HostConfig = {
47  NanoCpus?: number
48  CpuQuota?: number
49  CpuPeriod?: number
50  Memory?: number
51  DeviceRequests?: { Capabilities?: string[][] }[] | null
52}
53
54// `docker inspect <id>`: the container's own CPU and memory limits (absent when none is set),
55// and whether a device request asks for GPUs.
56export function parseInspect(out: string): { cpus?: number; memory?: number; hasGpu: boolean } {
57  const [c] = JSON.parse(out) as { HostConfig?: HostConfig }[]
58  const h = c?.HostConfig ?? {}
59  const cpus = h.NanoCpus ? h.NanoCpus / 1e9 : h.CpuQuota && h.CpuQuota > 0 && h.CpuPeriod ? h.CpuQuota / h.CpuPeriod : undefined
60  const hasGpu = (h.DeviceRequests ?? []).some(r => (r.Capabilities ?? []).flat().some(cap => cap.includes('gpu')))
61  return { cpus, memory: h.Memory ? h.Memory : undefined, hasGpu }
62}
63
64// `docker info --format '{{json .}}'`: what the Docker host gives a container without limits.
65export function parseInfo(out: string): { ncpu?: number; memTotal?: number } {
66  const i = JSON.parse(out) as { NCPU?: number; MemTotal?: number }
67  return { ncpu: i.NCPU || undefined, memTotal: i.MemTotal || undefined }
68}
69
70// RFC 3339 with nanoseconds, trailing zeros trimmed: pad the fraction so the text sorts by time.
71const timeKey = (ts: string) => ts.replace(/(?:\.(\d+))?Z$/, (_, frac: string | undefined) => `.${(frac ?? '').padEnd(9, '0')}Z`)
72
73// The container's stdout and stderr arrive on separate pipes; their timestamps put them back
74// in order. Ties keep stdout first.
75export function mergeLogs(stdout: string, stderr: string): string[] {
76  return [...stdout.split('\n'), ...stderr.split('\n')]
77    .filter(l => l.trim())
78    .map((l, i) => {
79      const sp = l.indexOf(' ')
80      const ts = sp > 0 ? l.slice(0, sp) : ''
81      const isTs = /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(ts)
82      return { key: isTs ? timeKey(ts) : '', text: isTs ? l.slice(sp + 1) : l, i }
83    })
84    .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : a.i - b.i))
85    .map(l => l.text)
86    .slice(-MAX_LINES)
87}
88
89export async function fetchDockerLogs(io: Io, prev: Logs): Promise<Logs> {
90  const t = targetOf(prev)
91  try {
92    const { exitCode, stdout, stderr } = await io.run(
93      [...base(t.context), 'logs', '--tail', String(MAX_LINES), '--timestamps', t.container_id],
94      { timeoutMs: DOCKER_TIMEOUT_MS },
95    )
96    if (exitCode !== 0) return { ...t, lines: prev.lines, error: lastLine(stderr, exitCode), checkedAt: await io.now() }
97    return { ...t, lines: mergeLogs(stdout, stderr), checkedAt: await io.now() }
98  } catch (err) {
99    return { ...t, lines: prev.lines, error: message(err), checkedAt: await io.now() }
100  }
101}
102
103export async function fetchDockerMetrics(io: Io, prev: Metrics): Promise<Metrics> {
104  const t = targetOf(prev)
105  const run = (args: string[]) => io.run([...base(t.context), ...args], { timeoutMs: DOCKER_TIMEOUT_MS })
106  const next: Metrics = { ...t, limits: prev.limits }
107  try {
108    if (!next.limits) {
109      const [inspect, info] = await Promise.all([run(['inspect', t.container_id]), run(['info', '--format', '{{json .}}'])])
110      if (inspect.exitCode !== 0) throw new Error(lastLine(inspect.stderr, inspect.exitCode))
111      const own = parseInspect(inspect.stdout)
112      const host = info.exitCode === 0 ? parseInfo(info.stdout) : {}
113      next.limits = { cpus: own.cpus ?? host.ncpu, cpusHost: own.cpus === undefined, memTotal: host.memTotal, hasGpu: own.hasGpu }
114      next.memLimit = own.memory
115    } else next.memLimit = prev.memLimit
116    const [stats, gpu] = await Promise.all([
117      run(['stats', '--no-stream', '--format', '{{json .}}', t.container_id]),
118      next.limits.hasGpu ? run(['exec', t.container_id, 'nvidia-smi']) : undefined,
119    ])
120    if (stats.exitCode !== 0) throw new Error(lastLine(stats.stderr, stats.exitCode))
121    const s = parseStats(stats.stdout)
122    if (s) Object.assign(next, { cores: s.cores, memUsed: s.memUsed, io: { net: s.net, block: s.block } })
123    else next.cpuMemNote = 'docker stats printed nothing'
124    if (gpu) {
125      next.gpus = gpu.exitCode === 0 ? parseNvidiaSmi(gpu.stdout) : undefined
126      if (!next.gpus) next.gpuNote = gpu.exitCode === 0 ? gpu.stdout.trim().split('\n')[0] : lastLine(gpu.stderr, gpu.exitCode)
127    }
128  } catch (err) {
129    next.error = message(err)
130  }
131  next.checkedAt = await io.now()
132  return next
133}
134