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…

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.
| Tab | Rows | Extras |
|---|---|---|
| Docker | docker ps: name, image, status, ports | a copy button per container name; logs and metrics buttons per container |
| Modal | one 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, age | Mine only / All runs; logs and metrics buttons per container |
| Render | recent deploys: state, service, environment, branch, commit, trigger, age, commit message, link | current git branch marked › |
| Vercel | recent 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 deployment | a 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.
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:
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.
/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.
| Tab | Needs |
|---|---|
| Docker | the docker CLI on your PATH and a running daemon |
| Modal | macOS or Linux, and the modal CLI on your PATH, logged in (modal token new) |
| Render | RENDER_API_KEY in the environment Claude Code starts in |
| Vercel | VERCEL_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 |
| Option / variable | Default | Effect |
|---|---|---|
providers | all four | Which tabs the pane has, in this order. |
docker_context | empty | The docker context to list. Empty follows DOCKER_HOST, then your current context. |
docker_all | false | Also list stopped containers (docker ps --all). |
docker_refresh_seconds | 2 | Docker refresh interval (1-3600). |
modal_environment | empty | The Modal environment to list. Empty follows MODAL_ENVIRONMENT, then your modal profile, then the workspace default. |
detail_refresh_seconds | 5 | How often a Modal or Docker container's logs and metrics panes refresh while open (2-3600). |
render_services | empty | Comma-separated Render service names to show. Empty shows every service, up to the cap. |
render_max_services | 10 | Most Render services to list deploys for (1-50). A full refresh makes one request per service. |
vercel_team | empty | Team id (team_...) or slug. Empty uses the team in .vercel/project.json, else your personal account. |
vercel_project | empty | Project id or name. Empty uses the linked project, else every project in the scope. |
deploy_max_rows | 15 | How many deploys the Render and Vercel tabs list, newest first (1-100). |
deploy_refresh_seconds | 60 | Render and Vercel refresh interval while nothing is building (15-3600). |
deploy_building_refresh_seconds | 10 | Render and Vercel refresh interval while a deploy is building (5-600). |
notify_on_finish | false | While 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) | unset | Used when modal_environment is empty. |
RENDER_API_KEY (env var) | unset | Render API key. |
VERCEL_TOKEN (env var) | unset | Vercel API token. |
Every option appears as a row in /config. Changes apply immediately.
From claude plugin validate --strict:
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).$.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 --jsonmodal 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.$.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).$.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.MODAL_ENVIRONMENT, RENDER_API_KEY, VERCEL_TOKEN; writes nothing.$.clock, $.session.cwd, $.ui.open/panes/copy/toast, $.command.register.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.
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 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 container logs fetches them; long lines are cut at the pane's edge./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.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./observe instead, and nothing polls.hooks/register.tsx 291 lines1import { 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}
291hooks/io.ts 16 lines1import 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}
16hooks/providers/index.ts 51 lines1import 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}
51hooks/providers/details.ts 20 lines1// 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}
20hooks/providers/detail-view.tsx 136 lines1// 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}
136hooks/tab-pane.tsx 192 lines1// 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}
192hooks/util.ts 39 lines1import 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 })
39hooks/providers/docker.ts 81 lines1import 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}
81hooks/providers/modal.ts 146 lines1import 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}
146hooks/providers/render.ts 180 lines1import 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}
180hooks/providers/vercel.ts 191 lines1import 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}
191hooks/providers/docker-detail.ts 134 lines1// 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