Agent skills for the Lightning AI platform: GPU Studios, batch jobs, deployments, sandboxes, the LLM gateway, shareable artifact links, and up-front cost…

Agent skills that teach AI coding agents (Claude Code, Codex, Cursor, and any agent that supports the SKILL.md format) how to use the Lightning AI platform: GPU Studios, batch jobs, model deployments, code-execution sandboxes, the LLM gateway, durable shareable artifact links, and up-front cost estimates for any of it.
| Skill | What it covers |
|---|---|
lightning-studios | Create, start, stop and manage cloud GPU Studios; switch machines, run commands, transfer files, SSH |
lightning-jobs | Launch and monitor batch jobs (single and multi-machine) on CPUs/GPUs, stream logs, show a live progress bar with ETA and setback tracking, SSH into running jobs or multi-machine workers, collect artifacts |
lightning-deployments | Deploy containers/APIs with autoscaling, manage releases, endpoints and auth |
lightning-sandboxes | Fast ephemeral VMs for safe code execution: run commands, background processes, file I/O, Docker (docker / docker compose) and public port URLs |
lightning-llm-gateway | Call hosted LLMs (OpenAI, Anthropic, open models) through Lightning's models API |
lightning-artifacts | Publish a file and get a durable, public lightning.ai/artifacts/<id> link that never expires and renders inline; list, revoke, and delete shares — entirely via the CLI with regular auth |
lightning-cost-estimation | Quote what a training run, fine-tune or deployment costs: live per-hour GPU/CPU prices for every cloud, spot rates, multi-node fan-out, and Drive storage |
Tutorial: teach a 4B model to call tools on a cloud GPU. It starts from a Mac or a Claude cloud session and one chat message.
All skills are built around the lightning-sdk Python package and its lightning CLI, plus the raw lightning api escape hatch for anything the SDK doesn't wrap.
This repo is also a plugin marketplace, so Claude Code can install all seven skills as one plugin and keep them updated. The plugin also adds live job progress bars above the prompt. From a shell (no Claude session needed, so it works in scripts and headless setups):
claude plugin marketplace add Lightning-AI/skills
claude plugin install lightning@lightning-ai
Or from inside a Claude Code session:
/plugin marketplace add Lightning-AI/skills
/plugin install lightning@lightning-ai
Plugin skills are namespaced, so they appear as /lightning:lightning-jobs, /lightning:lightning-studios, and so on. They still fire on their own when a task calls for them.
The skills.sh CLI installs into Claude Code, Cursor, Codex, and many other agents:
# interactive: pick skills and target agents
npx skills add Lightning-AI/skills
# install every skill for one agent, user-level, without prompts (swap codex for your agent)
npx skills add Lightning-AI/skills --agent codex --skill '*' --global --yes
# install a specific skill, e.g. just sandboxes
npx skills add Lightning-AI/skills -s lightning-sandboxes
# user-level (global) instead of the current project
npx skills add Lightning-AI/skills -g
Avoid --all unless you mean it: it selects every skill and every agent on the machine.
Or copy the skill folders straight into your agent's skills directory:
SKILLS="lightning-studios lightning-jobs lightning-deployments lightning-sandboxes lightning-llm-gateway lightning-artifacts lightning-cost-estimation"
# Claude Code (project-level)
mkdir -p .claude/skills && cp -r $SKILLS .claude/skills/
# Claude Code (user-level)
cp -r $SKILLS ~/.claude/skills/
uv or pip. There's no need to install the SDK first: skills use the lightning CLI in your current environment, installing or upgrading lightning-sdk there if it's missing or too old (uv tool install lightning-sdk or uvx are fallbacks)lightning login (browser sign-in, saved for later commands)LIGHTNING_API_KEY (plus LIGHTNING_USER_ID, optional) through that environment's secrets[!IMPORTANT] These skills create real, billed resources on your Lightning AI account (Studios, jobs, deployments, sandboxes). They ask before anything that spends money or makes something public, but you are responsible for what runs under your account.
lightning api <endpoint> for raw REST calls.Adding or editing a skill? See CONTRIBUTING.md for the SKILL.md format, the house-style section layout, the conventions above, and how to live-test every command against a control plane before opening a PR.
Apache-2.0. By contributing you agree your contributions are licensed the same way, and to follow the code of conduct.
hooks/job-progress/register.tsx 550 lines1// Live job progress inside Claude Code: the bars above the prompt, a tool that starts the poller
2// outside the sandbox, and a message to Claude for each event that needs a reply. It reads the
3// files lightning-jobs/progress.py writes (see that skill's "Live progress" section), so it works
4// beside the status line and the Monitor, and with any poller that writes the same files.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { RunCard, Watcher } from '../../types'
10import { createDelivery } from './delivery'
11import { BAR_H, BAR_W, barAlt, barSvg, hiddenCards, jobUrl, moreLine, toCards, type JobRef } from './desktop'
12import { FINAL_VISIBLE_FOR, isFinal, isStale, renderAll, type RunState } from './render'
13
14const rows = atom({ plugin: 'lightning', key: 'rows' } as const, [])
15const cards = atom({ plugin: 'lightning', key: 'cards' } as const, [])
16const moreCards = atom({ plugin: 'lightning', key: 'moreCards' } as const, 0)
17const watchers = atom({ plugin: 'lightning', key: 'watchers' } as const, {})
18
19const TOOL = 'watch_job'
20const REFRESH_MS = 2000
21const BATCH_MS = 1500
22const RELAUNCH_WAIT = '1800'
23const PYTHONS = [['python3'], ['python'], ['py', '-3']]
24// skills whose jobs and Studio runs report progress through progress.py
25const PROGRESS_SKILLS = /(^|:)lightning-(jobs|studios)$/
26// the poller's notes about the status line, which the mod replaces, so Claude has nothing to do with them
27const STATUS_LINE_HINT = /status[- ]line/
28// progress the bars already show (job_progress/events.py ROUTINE_KINDS)
29const ROUTINE_KINDS = ['milestone', 'stage', 'started', 'recovered', 'watching']
30// a poller or event feed started by hand, which the mod does better (see handNote)
31const HAND_WATCH = /(progress\.py['"]?\s+watch|lightning\s+job\s+watch)\b/
32const HAND_EVENTS = /progress\.py['"]?\s+events\b/
33// a line a run prints for the poller (the skill's progress protocol), not the word in prose
34const PROGRESS_LINE = /\bPROGRESS\s+\d+\s*\/\s*\d+|\bPROGRESS_PHASE\s+\S|\bPROGRESS_EXIT\s+-?\d/
35// a Monitor that picks those lines out of a log
36const PROGRESS_FILTER = /\bPROGRESS(_PHASE|_EXIT)?\b/
37const NUDGE_EVERY_MS = 10 * 60 * 1000
38
39type RunFile = { session?: string | null; jobs?: { kind?: string; name?: string; teamspace?: string | null }[] }
40
41/** The skill text's addendum: the mod does steps 2-4 of the skill's "Live progress" workflow. */
42export function skillNote(hasBar: boolean): string {
43 return [
44 '',
45 '## Live progress in this session',
46 '',
47 "The lightning plugin's job-progress mod is loaded, so the live-progress workflow is shorter:",
48 '',
49 `- **Start the poller with the \`mcp__lightning__${TOOL}\` tool**, not Bash: it takes the same`,
50 ' arguments as `progress.py watch` and runs it outside the sandbox, so there is nothing to approve.',
51 '- **Skip the Monitor and the status-line offer.** The mod sends you a message for each event that',
52 ' needs a reply (stalls, setbacks, failures, relaunches, the final state); act on those.',
53 hasBar
54 ? '- **The bars show above the prompt**, so there is no need to repeat routine progress.'
55 : '- **Nothing draws a bar here**, so the mod also sends routine progress: report it in a line.',
56 '',
57 ].join('\n')
58}
59
60/** `watch_job`'s input as `progress.py watch` arguments. */
61export function watchArgs(input: Record<string, unknown>): string[] {
62 const str = (k: string) => (typeof input[k] === 'string' && input[k] !== '' ? (input[k] as string) : undefined)
63 const args = str('job') ? [str('job')!] : []
64 for (const k of ['studio', 'log', 'run', 'teamspace', 'note', 'query']) {
65 const v = str(k)
66 if (v) args.push(`--${k}`, v)
67 }
68 return [...args, '--relaunch-wait', RELAUNCH_WAIT]
69}
70
71/** Lines of a child's stdout, from the pieces it arrives in. */
72async function* lines(stream: AsyncIterable<{ stream: string; text: string }>): AsyncGenerator<string> {
73 let buf = ''
74 for await (const { stream: which, text } of stream) {
75 if (which !== 'stdout') continue
76 buf += text
77 let i: number
78 while ((i = buf.indexOf('\n')) >= 0) {
79 const line = buf.slice(0, i).trim()
80 buf = buf.slice(i + 1)
81 if (line) yield line
82 }
83 }
84 if (buf.trim()) yield buf.trim()
85}
86
87// children and timers die with the module, so these start over on a reload too
88const feeds = new Set<string>()
89const pending: string[] = []
90let python: string[] | null = null
91let sdkWatch: boolean | null = null
92// an SDK watcher between its start and its first line, when refresh can't yet tell its run is fed
93let sdkStarting = 0
94let isFlushing = false
95let delivery: ReturnType<typeof createDelivery> | null = null
96// this session's runs whose poller is alive, as the last refresh saw them
97let liveRuns = 0
98let lastNudgeMs = 0
99let lastHandMs = 0
100
101async function stateDir($: EngineInterface): Promise<string> {
102 const own = await $.env.get('LIGHTNING_PROGRESS_DIR')
103 if (own) return own
104 const xdg = await $.env.get('XDG_STATE_HOME')
105 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
106 return `${xdg || `${home}/.local/state`}/lightning-progress`
107}
108
109async function canDraw($: EngineInterface): Promise<boolean> {
110 return (await $.session.surfaces()).some(s => s === 'terminal' || s === 'desktop')
111}
112
113/** The first Python on PATH; progress.py finds the SDK's own interpreter itself. */
114async function pythonCmd($: EngineInterface): Promise<string[]> {
115 if (python) return python
116 for (const cmd of PYTHONS) {
117 try {
118 const { exitCode } = await $.process.run([...cmd, '--version'], { timeoutMs: 10_000 })
119 if (exitCode === 0) return (python = cmd)
120 } catch {
121 // not installed under this name
122 }
123 }
124 throw new Error('no Python found (tried python3, python, py -3)')
125}
126
127async function progressCmd($: EngineInterface, args: string[]): Promise<{ argv: string[]; env: Record<string, string> }> {
128 const script = `${$.plugin.root}/lightning-jobs/progress.py`
129 // tags the run with this session, so the status line and the feed keep to it
130 return { argv: [...(await pythonCmd($)), script, ...args], env: { CLAUDE_CODE_SESSION_ID: await $.session.id() } }
131}
132
133/** Lines from the feeds go to Claude together, as one note, once they stop coming. */
134function tell($: EngineInterface, line: string): void {
135 pending.push(line)
136 if (isFlushing) return
137 isFlushing = true
138 $.clock.after(BATCH_MS, async () => {
139 isFlushing = false
140 const batch = pending.splice(0)
141 if (batch.length) await deliveryFor($).deliver(batch)
142 })
143}
144
145/** The note goes into the running turn as a user-role row Claude reads and the person doesn't see. */
146async function appendNote($: EngineInterface, text: string): Promise<boolean> {
147 try {
148 const out = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
149 return !('deny' in out && out.deny)
150 } catch (err) {
151 $.ui.log(`could not add the note to the running turn: ${String(err)}`, { to: 'debug' })
152 return false
153 }
154}
155
156async function submitNote($: EngineInterface, text: string): Promise<void> {
157 await $.prompt.submit({ text })
158}
159
160function deliveryFor($: EngineInterface): ReturnType<typeof createDelivery> {
161 delivery ??= createDelivery({ append: text => appendNote($, text), submit: text => submitNote($, text) })
162 return delivery
163}
164
165/** `progress.py events --run RUN`: the Monitor's feed, with its cursor, read by the mod. */
166function startFeed($: EngineInterface, run: string): void {
167 feeds.add(run)
168 void (async () => {
169 try {
170 const all = !(await canDraw($))
171 const { argv, env } = await progressCmd($, ['events', '--run', run, ...(all ? ['--all'] : [])])
172 for await (const line of lines($.process.spawn({ argv, env }))) {
173 if (!STATUS_LINE_HINT.test(line)) tell($, line)
174 }
175 } catch (err) {
176 $.ui.log(`${run}: event feed stopped: ${String(err)}`, { to: 'debug' })
177 } finally {
178 feeds.delete(run)
179 }
180 })()
181}
182
183/** Whether the installed `lightning` CLI has `job watch`, which writes the same files. */
184async function hasSdkWatch($: EngineInterface): Promise<boolean> {
185 if (sdkWatch !== null) return sdkWatch
186 try {
187 sdkWatch = (await $.process.run(['lightning', 'job', 'watch', '--help'], { timeoutMs: 20_000 })).exitCode === 0
188 } catch {
189 sdkWatch = false
190 }
191 return sdkWatch
192}
193
194type WatchEvent = { kind?: string; run?: string; msg?: string }
195
196/** The SDK's `--json` lines: events, plus state snapshots the mod reads from the files anyway. */
197export function parseWatchLine(line: string): WatchEvent | null {
198 try {
199 const e = JSON.parse(line) as unknown
200 return e && typeof e === 'object' ? (e as WatchEvent) : null
201 } catch {
202 return null
203 }
204}
205
206/** Which events Claude hears about: those `progress.py events` passes on, or all where no bar shows. */
207export function isWanted(kind: string | undefined, hasBar: boolean): boolean {
208 if (!kind || kind === 'state' || kind === 'hint') return false
209 return !hasBar || !ROUTINE_KINDS.includes(kind)
210}
211
212/** Starts the poller for the session's life; resolves with what it said first. Jobs go to
213 * `lightning job watch --json` where the CLI has it, whose events the mod reads straight off its
214 * output; Studio logs, and older CLIs, to `progress.py watch`, with `progress.py events` as the feed. */
215function startWatch($: EngineInterface, args: string[]): Promise<string> {
216 return new Promise(resolve => {
217 void (async () => {
218 let first: string | null = null
219 let run: string | null = null
220 let isCounted = false
221 let hasEnded = false
222 const settle = () => {
223 if (isCounted) sdkStarting -= 1
224 isCounted = false
225 }
226 const isJob = !args.includes('--studio')
227 try {
228 const viaSdk = isJob && (await hasSdkWatch($))
229 const hasBar = await canDraw($)
230 const env = { CLAUDE_CODE_SESSION_ID: await $.session.id() }
231 const argv = viaSdk ? ['lightning', 'job', 'watch', ...args, '--json'] : (await progressCmd($, ['watch', ...args])).argv
232 if (viaSdk) {
233 sdkStarting += 1
234 isCounted = true
235 }
236 const child = $.process.spawn({ argv, env })
237 for await (const line of lines(child)) {
238 const e = viaSdk ? parseWatchLine(line) : null
239 if (first === null) {
240 first = viaSdk ? (e?.msg ?? line) : line
241 resolve(first)
242 }
243 // the run comes from its `watching` line, which may follow a hint or a warning
244 if (run === null) {
245 run = viaSdk ? (e?.kind === 'watching' ? (e.run ?? null) : null) : (/^run (\S+): watching/.exec(line)?.[1] ?? null)
246 if (run) {
247 settle()
248 // this child is the run's feed; restarted after a reload, it carries on as the same run
249 if (viaSdk) feeds.add(run)
250 const again = args.includes('--run') ? args : [...args, '--run', run]
251 await keepWatchers($, w => ({ ...w, [run!]: { args: again } }))
252 continue
253 }
254 }
255 if (e?.msg && isWanted(e.kind, hasBar)) tell($, e.msg)
256 }
257 const { code, signal } = await child.result
258 // killed (a reload or a restart of the session) rather than done: keep it saved to start again
259 hasEnded = signal === null
260 if (first === null) resolve(`the poller exited with code ${code} before it started; check its arguments`)
261 } catch (err) {
262 resolve(`could not start the poller: ${String(err)}`)
263 } finally {
264 settle()
265 if (run) {
266 feeds.delete(run)
267 if (hasEnded) await keepWatchers($, w => Object.fromEntries(Object.entries(w).filter(([k]) => k !== run)))
268 }
269 }
270 })()
271 })
272}
273
274/** The session's pollers, kept in its state (a hot reload) and in the store under its id (the
275 * session itself restarting, as the desktop app does), so either brings them back. */
276async function keepWatchers($: EngineInterface, fn: (w: Record<string, Watcher>) => Record<string, Watcher>): Promise<void> {
277 await update($, watchers, fn)
278 try {
279 const key = `watchers:${await $.session.id()}`
280 const now = await read($, watchers)
281 if (Object.keys(now).length) await $.store.set(key, now)
282 else await $.store.delete(key)
283 } catch (err) {
284 // the poller runs on regardless; it just won't come back after a restart of the session
285 $.ui.log(`could not save the pollers for a restart: ${String(err)}`, { to: 'debug' })
286 }
287}
288
289async function refresh($: EngineInterface): Promise<void> {
290 const dir = await stateDir($)
291 const nowMs = await $.clock.now()
292 const session = await $.session.id()
293 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
294 try {
295 entries = await $.fs.list(`${dir}/state`)
296 } catch {
297 // no poller has run on this machine yet
298 }
299 const states: RunState[] = []
300 const jobs: Record<string, JobRef> = {}
301 let live = 0
302 for (const f of entries) {
303 if (f.kind !== 'file' || !f.name.endsWith('.json') || nowMs - f.mtimeMs > FINAL_VISIBLE_FOR * 1000) continue
304 try {
305 const s = JSON.parse(await $.fs.read(`${dir}/state/${f.name}`)) as RunState
306 const runfile = JSON.parse(await $.fs.read(`${dir}/runs/${s.run}.json`).catch(() => '{}')) as RunFile
307 // this session's runs, plus runs started outside Claude Code, as the status line shows
308 if (runfile.session && runfile.session !== session) continue
309 states.push(s)
310 const last = runfile.jobs?.at(-1)
311 if (last?.kind === 'job' && last.name) jobs[s.run] = { name: last.name, teamspace: last.teamspace ?? null }
312 if (runfile.session === session && !isFinal(s) && !isStale(s, nowMs / 1000)) live += 1
313 if (runfile.session === session && !isFinal(s) && !feeds.has(s.run) && !sdkStarting) startFeed($, s.run)
314 } catch {
315 // a state file mid-write; the next tick reads it
316 }
317 }
318 liveRuns = live
319 const now = nowMs / 1000
320 const next = renderAll(states, now)
321 if (JSON.stringify(next) !== JSON.stringify(await read($, rows))) await update($, rows, () => next)
322 const nextCards = toCards(states, jobs, now)
323 if (JSON.stringify(nextCards) !== JSON.stringify(await read($, cards))) await update($, cards, () => nextCards)
324 const more = hiddenCards(states, now)
325 if (more !== (await read($, moreCards))) await update($, moreCards, () => more)
326}
327
328/** Stops a run's job after the person confirms, and tells Claude, so it doesn't wait on it. */
329async function stopJob($: EngineInterface, card: RunCard): Promise<void> {
330 if (!card.job) return
331 const { name, teamspace } = card.job
332 let answer: string
333 try {
334 answer = await $.ui.ask(`Stop the Lightning job ${name}? It stops billing, and the run ends as stopped.`, [
335 'Stop it',
336 'Keep it running',
337 ])
338 } catch {
339 return // dismissed
340 }
341 if (answer !== 'Stop it') return
342 try {
343 const argv = ['lightning', 'job', 'stop', name, ...(teamspace ? ['--teamspace', teamspace] : [])]
344 const { exitCode, stderr } = await $.process.run(argv, { timeoutMs: 60_000 })
345 if (exitCode !== 0) throw new Error(stderr.trim().split('\n').at(-1) || `exit code ${exitCode}`)
346 $.ui.toast(`Stopped ${name}`)
347 tell($, `${card.run}: the user stopped job ${name} from the progress band`)
348 } catch (err) {
349 $.ui.toast(`Could not stop ${name}: ${String(err).slice(0, 120)}`, { timeoutMs: 8000 })
350 }
351}
352
353/** What Claude reads about a command that starts a poller or an event feed by hand, which the mod
354 * does itself, or null. A hand-started poller needs the sandbox lifted and breaks on shell
355 * quoting; a Monitor on the events repeats the notes the mod already sends. The skill's own note
356 * says this, but only where its text reaches Claude. A Bash command only reminds, since the text
357 * can be a script or a file being written; a Monitor on the events is refused. */
358export function handNote(command: string): string | null {
359 if (HAND_EVENTS.test(command)) {
360 return (
361 "No Monitor needed: the lightning plugin's job-progress mod already follows this run's events and sends " +
362 `you each one that needs a reply. Start pollers with \`mcp__lightning__${TOOL}\`.`
363 )
364 }
365 if (HAND_WATCH.test(command)) {
366 return (
367 `The lightning plugin's job-progress mod is loaded: start pollers with the \`mcp__lightning__${TOOL}\` ` +
368 'tool (load it with ToolSearch `select:mcp__lightning__watch_job` if needed), not Bash. It takes the same ' +
369 'arguments as `progress.py watch` (job, or studio and log; run, teamspace, note), runs outside the sandbox, ' +
370 'draws the bars and sends you the events that need a reply. One call per job; skip the Monitor.'
371 )
372 }
373 return null
374}
375
376/** Whether a Bash call's output, or a Monitor's command, shows a run reporting progress with no
377 * poller. A Bash command that only mentions the word (a commit message, a grep) is not one. */
378export const reportsProgress = (command: string, output: string, isMonitor = false): boolean =>
379 !HAND_WATCH.test(command) &&
380 !HAND_EVENTS.test(command) &&
381 (PROGRESS_LINE.test(output) || (isMonitor && PROGRESS_FILTER.test(command)))
382
383export const NUDGE =
384 'This run reports PROGRESS lines, but no progress poller is watching any run of this session, so the ' +
385 `person sees no bar. Start one now with \`mcp__lightning__${TOOL}\` (a job name, or studio and log), ` +
386 'one per job or log, rather than following the log yourself.'
387
388/** The reminders for a Bash or Monitor call: a poller started by hand, or a run with none. Each at
389 * most every NUDGE_EVERY_MS. */
390async function nudgesFor($: EngineInterface, command: string, output: string, isMonitor = false): Promise<string[]> {
391 const out: string[] = []
392 const now = await $.clock.now().catch(() => 0)
393 if (!now) return out
394 const hand = handNote(command)
395 if (hand && now - lastHandMs >= NUDGE_EVERY_MS) {
396 lastHandMs = now
397 out.push(hand)
398 }
399 if (!liveRuns && reportsProgress(command, output, isMonitor) && now - lastNudgeMs >= NUDGE_EVERY_MS) {
400 lastNudgeMs = now
401 out.push(NUDGE)
402 }
403 return out
404}
405
406export const register: Register = on => {
407 on('session.start', async ($, e, next) => {
408 const started = await next(e)
409 await $.tool.register({
410 name: TOOL,
411 description:
412 'Start the lightning-jobs live-progress poller (`lightning job watch`, or `progress.py watch` where the ' +
413 'CLI lacks it) for a Lightning job, or for a ' +
414 'log file in a Studio, outside the agent sandbox. The progress bars then show above the prompt and ' +
415 'you get a message for each event that needs a reply. Returns the run name the poller tracks.',
416 inputSchema: {
417 type: 'object',
418 properties: {
419 job: { type: 'string', description: 'job name (or use studio and log)' },
420 studio: { type: 'string', description: 'Studio whose log file to follow instead of a job' },
421 log: { type: 'string', description: 'log file inside the Studio, ending with PROGRESS_EXIT <code>' },
422 run: { type: 'string', description: 'run this belongs to, e.g. to relaunch a failed run into it' },
423 teamspace: { type: 'string', description: 'owner/teamspace (default: the CLI configured one)' },
424 note: { type: 'string', description: 'why this job was (re)launched' },
425 query: { type: 'string', description: 'server-side log filter, e.g. PROGRESS for very chatty jobs' },
426 },
427 },
428 })
429 // a reload or a restart of the session killed the pollers this mod started; start them again
430 const stored = ((await $.store.get(`watchers:${await $.session.id()}`).catch(() => undefined)) ?? {}) as Record<string, Watcher>
431 for (const w of Object.values({ ...stored, ...(await read($, watchers)) })) void startWatch($, w.args)
432 $.clock.every(REFRESH_MS, () => refresh($))
433 void refresh($)
434 return started
435 })
436
437 on('tool.call', { tool: `mcp__lightning__${TOOL}` }, async ($, e) => {
438 const args = watchArgs(e as unknown as Record<string, unknown>)
439 if (!args[0] || args[0].startsWith('--')) {
440 if (!(typeof e.studio === 'string' && typeof e.log === 'string')) {
441 return { deny: 'Give a job name, or both studio and log.' }
442 }
443 }
444 const first = await startWatch($, args)
445 void refresh($)
446 return { result: first }
447 })
448
449 // a step that calls a tool is followed by another request, which reads the notes held for it
450 on('tool.call', async ($, e, next) => {
451 // a failure here must not hold up the tool
452 if (!e.agentId) await deliveryFor($).toolStarted().catch(err => $.ui.log(`could not pass on the held notes: ${String(err)}`, { to: 'debug' }))
453 return next(e)
454 })
455
456 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
457 const out = await next(e)
458 if ('deny' in out && out.deny) return out
459 const nudges = await nudgesFor($, e.command, out.text ?? '')
460 return nudges.length ? { ...out, context: [...(out.context ?? []), ...nudges] } : out
461 })
462
463 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
464 if (e.command && HAND_EVENTS.test(e.command)) return { deny: handNote(e.command)! }
465 const out = await next(e)
466 if (!e.command || ('deny' in out && out.deny)) return out
467 const nudges = await nudgesFor($, e.command, '', true)
468 return nudges.length ? { ...out, context: [...(out.context ?? []), ...nudges] } : out
469 })
470
471 // notes appended mid-turn reach Claude with the turn's next request; see delivery.ts
472 on('turn.start', async ($, e, next) => {
473 deliveryFor($).turnStarted()
474 return next(e)
475 })
476 on('turn.step', async function* ($, e, next) {
477 if (!e.agentId) deliveryFor($).stepStarted()
478 return yield* next(e)
479 })
480 on('turn.complete', async ($, e, next) => {
481 const out = await next(e)
482 if (!e.agentId) for (const line of deliveryFor($).turnEnded()) tell($, line)
483 return out
484 })
485
486 on('skill.prompt', async ($, e, next) => {
487 const out = await next(e)
488 if (!PROGRESS_SKILLS.test(e.skill)) return out
489 return { text: out.text + skillNote(await canDraw($)) }
490 })
491
492 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
493 if (e.props.hasSurvey) return next(e)
494 if (e.surface === 'desktop') {
495 const list = await read($, cards)
496 if (!list.length) return next(e)
497 const more = await read($, moreCards)
498 const cloud = (await $.env.get('LIGHTNING_CLOUD_URL')) || undefined
499 const { Box, Button, Link, Svg, Text } = $.ui.resolve(e)
500 return (
501 <Box flexDirection="column" gap={1}>
502 {list.map(c => {
503 const url = c.job ? jobUrl(c.job, cloud) : null
504 return (
505 <Box key={`run:${c.run}`} flexDirection="column">
506 <Box flexDirection="row" gap={1}>
507 <Text bold dimColor={c.isStale}>
508 {c.icon} {c.run}
509 </Text>
510 <Text dimColor wrap="truncate">
511 {c.headline}
512 {c.cost ? ` · ${c.cost}` : ''}
513 </Text>
514 </Box>
515 <Svg source={barSvg(c)} alt={barAlt(c)} width={BAR_W} height={BAR_H} />
516 {c.detail ? (
517 <Text dimColor wrap="truncate">
518 {c.detail}
519 </Text>
520 ) : null}
521 {url || (c.job && !c.isFinal) ? (
522 <Box flexDirection="row" gap={2}>
523 {url ? <Link href={url} label="Open in Lightning" /> : null}
524 {c.job && !c.isFinal ? (
525 <Button key={`stop:${c.run}`} label="Stop job" onPress={() => void stopJob($, c)} />
526 ) : null}
527 </Box>
528 ) : null}
529 </Box>
530 )
531 })}
532 {more ? <Text dimColor>{moreLine(more)}</Text> : null}
533 </Box>
534 )
535 }
536 const list = await read($, rows)
537 if (!list.length) return next(e)
538 const { Box, Text } = $.ui.resolve(e)
539 return (
540 <Box flexDirection="column">
541 {list.slice(0, e.props.maxRows).map((r, i) => (
542 <Text key={i} dimColor={r.isStale} wrap="truncate">
543 {r.text}
544 </Text>
545 ))}
546 </Box>
547 )
548 })
549}
550hooks/job-progress/delivery.ts 70 lines1// How a note about job progress reaches Claude. Mid-turn it joins the running turn, which reads it
2// with its next model request: a prompt submitted then would wait for the turn to end and arrive
3// stale, starting a turn of its own just to say it knew. Idle, the note starts a turn, since
4// something needs a reply.
5//
6// A note added while Claude writes a reply that calls no tool is never read in that turn, yet it
7// stays in the conversation, so sending it again when the turn ends would repeat it. So a note
8// joins the turn only when another request is sure to follow: before the turn's first request, or
9// once the current step has called a tool. Otherwise it waits, and goes in at the step's first
10// tool call, or as a turn of its own when the turn ends.
11
12export type DeliveryIO = {
13 /** Adds the note to the running turn; false when that was refused or failed. */
14 append: (text: string) => Promise<boolean>
15 /** Starts a turn with the note. */
16 submit: (text: string) => Promise<void>
17}
18
19export const NOTE_HEAD = 'Lightning job progress:'
20
21export const noteText = (lines: string[]): string => [NOTE_HEAD, ...lines.map(l => `- ${l}`)].join('\n')
22
23export function createDelivery(io: DeliveryIO) {
24 let isBusy = false
25 // another request is sure to follow: none has started yet, or the current step called a tool
26 let isFollowed = false
27 let held: string[] = []
28 let unseen: string[] = []
29
30 async function append(lines: string[]): Promise<boolean> {
31 if (!(await io.append(noteText(lines)))) return false
32 unseen.push(...lines)
33 return true
34 }
35
36 return {
37 turnStarted(): void {
38 isBusy = true
39 isFollowed = true
40 },
41 /** A model request of the main loop began: it carries every note appended before it. */
42 stepStarted(): void {
43 unseen = []
44 isFollowed = false
45 },
46 /** The main loop's step called a tool, so another request follows: held notes go in now. */
47 async toolStarted(): Promise<void> {
48 isFollowed = true
49 if (!isBusy || !held.length) return
50 const lines = held.splice(0)
51 if (!(await append(lines))) held.unshift(...lines)
52 },
53 /** The turn ended; returns the lines it never read, to deliver again. */
54 turnEnded(): string[] {
55 isBusy = false
56 isFollowed = false
57 return [...unseen.splice(0), ...held.splice(0)]
58 },
59 async deliver(lines: string[]): Promise<'appended' | 'held' | 'submitted'> {
60 if (isBusy && !isFollowed) {
61 held.push(...lines)
62 return 'held'
63 }
64 if (isBusy && (await append(lines))) return 'appended'
65 await io.submit(noteText(lines))
66 return 'submitted'
67 },
68 }
69}
70hooks/job-progress/desktop.ts 203 lines1// The desktop app's view of the runs: a card per run, drawn as an SVG bar with a stage timeline and
2// a line of text, where the terminal draws the ASCII rows in render.ts. Same state files, same order.
3
4import type { CardStage, RunCard } from '../../types'
5import {
6 byDisplayOrder,
7 fmtDuration,
8 ICONS,
9 isFinal,
10 isShown,
11 isStale,
12 jobFraction,
13 peakOf,
14 type RunState,
15} from './render'
16
17// a fourth card no longer fits the band; the rest are counted on a line below the cards
18export const MAX_CARDS = 3
19export const BAR_W = 480
20export const BAR_H = 12
21
22// mid-tone colors that read on the app's light and dark themes alike
23const COLOR = {
24 track: '#8b8b8b',
25 run: '#3b82f6',
26 done: '#22c55e',
27 lost: '#f59e0b',
28 bad: '#ef4444',
29}
30
31export type JobRef = RunCard['job']
32
33const RUNNING = ['starting', 'running', 'recovering']
34
35function stagesOf(s: RunState, now: number): CardStage[] {
36 const attempt = s.attempt_no || 1
37 const seen = (s.stages ?? []).filter(st => (st.attempt ?? 1) === attempt)
38 const out: CardStage[] = seen.map(st => {
39 const isActive = st.end === null
40 const hasReading = isActive && st.name === s.stage && !!s.total && s.step !== null
41 return {
42 name: st.name,
43 // a failed or stopped run's last stage stays the open one, drawn in the run's color
44 state: isActive && s.phase !== 'done' ? 'active' : 'done',
45 seconds: (st.end ?? now) - st.start,
46 fraction: hasReading ? Math.max(0, Math.min(1, s.step! / s.total!)) : null,
47 }
48 })
49 // the stages still to come, named by their number: the job declares only how many there are
50 for (let i = out.length; i < (s.stage_count ?? 0); i++) {
51 out.push({ name: `stage ${i + 1}`, state: 'todo', seconds: null, fraction: null })
52 }
53 return out
54}
55
56function headlineOf(s: RunState, now: number, fraction: number | null): string {
57 const since = (t: number | null) => (t ? fmtDuration(now - t) : '…')
58 const parts: string[] = []
59 const pctText = fraction !== null ? `${Math.floor(fraction * 100)}%` : null
60 switch (s.phase) {
61 case 'pending':
62 return `${s.pending_note || 'waiting for machine'} · ${since(s.pending_since)}`
63 case 'stalled':
64 return [pctText, `stalled ${since(s.last_sample_at)}`].filter(Boolean).join(' · ')
65 case 'waiting':
66 return `failed · waiting for relaunch ${since(s.issue_since)}`
67 case 'done': {
68 const took = `done in ${fmtDuration((s.finished_at || now) - (s.started_at || now))}`
69 return s.setbacks.length ? `${took} · setbacks cost ${fmtDuration(s.lost_s)}` : took
70 }
71 }
72 if (!RUNNING.includes(s.phase)) return s.phase
73 if (pctText) parts.push(pctText)
74 if (s.total && s.step !== null && s.step >= s.total) parts.push(`${s.stage ? s.stage + ' ' : ''}finished`)
75 else if (s.total) parts.push(`ETA ${fmtDuration(s.eta_s)}`)
76 else parts.push(`${s.stage || 'starting'} · ${since(s.stage_since || s.started_at)}`)
77 if (s.setbacks.length) parts.push(`↺${s.setbacks.length}` + (s.lost_s >= 1 ? ` (+${fmtDuration(s.lost_s)})` : ''))
78 return parts.join(' · ')
79}
80
81function detailOf(s: RunState, now: number): string | null {
82 if ((s.phase === 'stalled' || s.phase === 'waiting') && s.last_error) return s.last_error.slice(0, 160)
83 if (s.stage && s.stage_count && !isFinal(s)) {
84 const tail = s.total ? '' : ` · no progress reported yet · ${fmtDuration(now - (s.stage_since || now))}`
85 return `stage ${s.stage_index}/${s.stage_count}: ${s.stage}${tail}`
86 }
87 if (isStale(s, now)) return 'stale: is the poller still running?'
88 return null
89}
90
91export function toCard(s: RunState, job: JobRef, now: number): RunCard {
92 const fraction = jobFraction(s) ?? (s.total && s.step !== null ? Math.max(0, Math.min(1, s.step / s.total)) : null)
93 const peak = peakOf(s)
94 const peakFraction = !s.stage_count && s.total && peak && s.step !== null && peak > s.step ? Math.min(1, peak / s.total) : null
95 return {
96 run: s.run,
97 icon: ICONS[s.phase] ?? '?',
98 phase: s.phase,
99 isFinal: isFinal(s),
100 isStale: isStale(s, now),
101 fraction: s.phase === 'done' ? 1 : fraction,
102 peak: peakFraction,
103 stages: stagesOf(s, now),
104 headline: headlineOf(s, now, s.phase === 'done' ? 1 : fraction),
105 detail: detailOf(s, now),
106 cost: s.cost !== null && s.cost !== undefined ? `$${s.cost.toFixed(2)}` : null,
107 job,
108 }
109}
110
111/** The cards for every shown run, live ones first, as the terminal orders its rows. */
112export function toCards(states: RunState[], jobs: Record<string, JobRef>, now: number): RunCard[] {
113 return states
114 .filter(s => isShown(s, now))
115 .sort(byDisplayOrder)
116 .slice(0, MAX_CARDS)
117 .map(s => toCard(s, jobs[s.run] ?? null, now))
118}
119
120/** How many shown runs didn't get a card. */
121export const hiddenCards = (states: RunState[], now: number): number =>
122 Math.max(0, states.filter(s => isShown(s, now)).length - MAX_CARDS)
123
124/** The line under the cards that counts the runs without one. */
125export const moreLine = (n: number): string => `+${n} more run${n > 1 ? 's' : ''}`
126
127/** The job's page on Lightning, as the SDK's `Job.link` builds it. */
128export function jobUrl(job: NonNullable<JobRef>, cloud = 'https://lightning.ai'): string | null {
129 if (!job.teamspace || !job.teamspace.includes('/')) return null
130 const [owner, ts] = job.teamspace.split('/')
131 const enc = encodeURIComponent
132 return `${cloud.replace(/\/$/, '')}/${enc(owner!)}/${enc(ts!)}/jobs/${enc(job.name)}?app_id=jobs`
133}
134
135const esc = (t: string) =>
136 t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
137
138const rect = (x: number, w: number, fill: string, opacity = 1) =>
139 w <= 0
140 ? ''
141 : `<rect x="${x.toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${BAR_H}" rx="3" fill="${fill}"` +
142 (opacity < 1 ? ` fill-opacity="${opacity}"` : '') +
143 '/>'
144
145// a stage or run that has started but not reported progress yet: a dashed outline with a slow
146// pulse, since a faint full-width fill reads as a full bar on the dark theme
147const PULSE = '<animate attributeName="stroke-opacity" values="0.4;1;0.4" dur="2s" repeatCount="indefinite"/>'
148const waiting = (x: number, w: number, stroke: string) =>
149 w <= 0
150 ? ''
151 : `<rect x="${(x + 0.5).toFixed(1)}" y="0.5" width="${(w - 1).toFixed(1)}" height="${BAR_H - 1}" rx="3" ` +
152 `fill="none" stroke="${stroke}" stroke-width="1" stroke-dasharray="4 3">${PULSE}</rect>`
153
154function fillColor(c: RunCard): string {
155 if (c.phase === 'done') return COLOR.done
156 if (c.phase === 'stopped') return COLOR.track
157 if (['failed', 'waiting', 'abandoned', 'stalled'].includes(c.phase)) return COLOR.bad
158 return COLOR.run
159}
160
161/** The bar as an SVG document: one segment per stage when the run has them, else one bar with the
162 * ground lost to a setback shaded between now and the peak. Each segment carries a hover title. */
163export function barSvg(c: RunCard): string {
164 const parts: string[] = []
165 if (c.stages.length) {
166 const gap = 3
167 const w = (BAR_W - gap * (c.stages.length - 1)) / c.stages.length
168 c.stages.forEach((st, i) => {
169 const x = i * (w + gap)
170 const took = st.seconds !== null ? ` · ${fmtDuration(st.seconds)}` : ''
171 const pct = st.fraction !== null ? ` · ${Math.floor(st.fraction * 100)}%` : ''
172 const title = `<title>${esc(`${st.name}${took}${pct}`)}</title>`
173 let seg = rect(x, w, COLOR.track, 0.25)
174 if (st.state === 'done') seg = rect(x, w, COLOR.done)
175 else if (st.state === 'active' && st.fraction !== null) seg += rect(x, w * st.fraction, fillColor(c))
176 else if (st.state === 'active') seg = c.isFinal ? rect(x, w, fillColor(c), 0.6) : rect(x, w, COLOR.track, 0.25) + waiting(x, w, fillColor(c))
177 parts.push(`<g>${title}${seg}</g>`)
178 })
179 } else {
180 const f = c.fraction ?? 0
181 parts.push(rect(0, BAR_W, COLOR.track, 0.25))
182 if (c.peak !== null && c.peak > f) parts.push(rect(BAR_W * f, BAR_W * (c.peak - f), COLOR.lost, 0.7))
183 if (c.fraction === null && !c.isFinal && c.phase !== 'pending') {
184 parts.push(waiting(0, BAR_W, fillColor(c)))
185 } else {
186 parts.push(rect(0, BAR_W * f, fillColor(c)))
187 }
188 parts.push(`<title>${esc(`${c.run} · ${c.headline}`)}</title>`)
189 }
190 return (
191 `<svg xmlns="http://www.w3.org/2000/svg" width="${BAR_W}" height="${BAR_H}" ` +
192 `viewBox="0 0 ${BAR_W} ${BAR_H}">${parts.join('')}</svg>`
193 )
194}
195
196/** What the bar says, for a reader that can't see it. */
197export function barAlt(c: RunCard): string {
198 const stages = c.stages.length
199 ? ` Stages: ${c.stages.map(st => `${st.name} ${st.state}`).join(', ')}.`
200 : ''
201 return `${c.run}: ${c.headline}.${stages}`
202}
203hooks/job-progress/render.ts 241 lines1// The bars, drawn from the poller's state files. A port of lightning-jobs/job_progress/statusline.py
2// and core.py: keep the two in step, so the band and the status line say the same thing.
3
4import type { BandRow } from '../../types'
5
6export type Stage = { name: string; start: number; end: number | null; attempt?: number }
7
8// The fields of a state/<run>.json file the bars read; the poller writes many more.
9export type RunState = {
10 run: string
11 phase: string
12 step: number | null
13 total: number | null
14 epoch: number | null
15 peak: number | null
16 peak_epoch: number | null
17 eta_s: number | null
18 stage: string | null
19 stage_since: number | null
20 stage_index: number | null
21 stage_count: number | null
22 stages: Stage[]
23 attempt_no: number
24 setbacks: unknown[]
25 lost_s: number
26 last_error: string | null
27 last_sample_at: number | null
28 issue_since: number | null
29 pending_since: number | null
30 pending_note: string | null
31 started_at: number | null
32 finished_at: number | null
33 updated_at: number | null
34 cost: number | null
35}
36
37export type Row = BandRow
38
39export const BAR_WIDTH = 20
40export const FINAL_PHASES = ['done', 'failed', 'stopped', 'abandoned']
41export const STATE_STALE_AFTER = 60
42export const FINAL_VISIBLE_FOR = 600
43export const MAX_ROWS = 10
44
45export const ICONS: Record<string, string> = {
46 pending: '⏳',
47 starting: '▶',
48 running: '▶',
49 recovering: '⟳',
50 stalled: '⚠',
51 waiting: '✖',
52 done: '✔',
53 failed: '✖',
54 stopped: '■',
55 abandoned: '✖',
56}
57
58export const isFinal = (s: RunState): boolean => FINAL_PHASES.includes(s.phase)
59
60export function fmtDuration(seconds: number | null | undefined): string {
61 if (seconds === null || seconds === undefined) return '…'
62 const s = Math.max(0, Math.round(seconds))
63 if (s < 60) return `${s}s`
64 if (s < 3600) return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
65 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
66}
67
68export function pct(step: number | null, total: number | null): number | null {
69 if (step === null || !total) return null
70 return Math.max(0, Math.min(100, Math.floor((100 * step) / total)))
71}
72
73const pad3 = (n: number | null): string => String(n ?? 'None').padStart(3)
74
75// Python rounds halves to even; match it so the bars agree to the cell.
76function roundHalfEven(x: number): number {
77 const r = Math.round(x)
78 return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r
79}
80
81/** `▓` done now, `▒` ground lost to a setback (current → peak), `░` still to do. */
82export function bar(step: number, peak: number, total: number, width = BAR_WIDTH): string {
83 const cur = Math.max(0, Math.min(width, roundHalfEven((width * step) / total)))
84 const top = Math.max(cur, Math.min(width, roundHalfEven((width * peak) / total)))
85 return '▓'.repeat(cur) + '▒'.repeat(top - cur) + '░'.repeat(width - top)
86}
87
88function stageTrack(s: RunState, now: number, keep = 3): string {
89 const stages = s.stages ?? []
90 const parts = stages
91 .slice(-(keep + 1))
92 .map(st =>
93 st.end === null ? `▸ ${st.name} ${fmtDuration(now - st.start)}` : `${st.name} ✔ ${fmtDuration(st.end - st.start)}`,
94 )
95 if (stages.length > keep + 1) parts.unshift('…')
96 return parts.join(' · ')
97}
98
99export function jobFraction(s: RunState): number | null {
100 if (!s.stage_count) return null
101 const frac = s.total && s.step !== null ? s.step / s.total : 0
102 return Math.max(0, Math.min(1, ((s.stage_index || 1) - 1 + Math.min(frac, 1)) / s.stage_count))
103}
104
105export const peakOf = (s: RunState): number | null => (s.peak_epoch === s.epoch ? s.peak : s.step)
106export const isStale = (s: RunState, now: number): boolean =>
107 !isFinal(s) && !!s.updated_at && now - s.updated_at > STATE_STALE_AFTER
108
109export function renderLine(s: RunState, now: number): Row {
110 const { phase, run, step, total, stage } = s
111 const peak = peakOf(s)
112 const live = !isFinal(s)
113 const stageOnly = !!stage && !total && ['starting', 'running', 'recovering'].includes(phase)
114 let head = `${ICONS[phase] ?? '?'} ${run}` + (stage && live && !stageOnly ? ` [${stage}]` : '')
115 if (stageOnly && s.stage_count) {
116 const done = Math.max(0, Math.min((s.stage_index || 1) - 1, s.stage_count))
117 head += ` ${bar(done, done, s.stage_count)} stage ${s.stage_index}/${s.stage_count}`
118 }
119 if (total) {
120 head += ` ${bar(step || 0, peak || step || 0, total)} ${pad3(pct(step, total))}%`
121 if (s.epoch !== null) head += ` ep${s.epoch}`
122 if (s.setbacks.length) head += ` ↺${s.setbacks.length}`
123 }
124 const since = (t: number | null) => (t ? fmtDuration(now - t) : '…')
125 const cause = s.last_error
126 let tail: string
127 if (phase === 'pending') {
128 tail = `${s.pending_note || 'waiting for machine'} · ${since(s.pending_since)}`
129 } else if (stageOnly) {
130 tail = stageTrack(s, now)
131 } else if (phase === 'starting' || (['running', 'recovering'].includes(phase) && !total)) {
132 tail = `${stage || 'starting'} · ${since(s.stage_since || s.started_at)}`
133 } else if (['running', 'recovering'].includes(phase) && step !== null && total && step >= total) {
134 tail = `${stage ? stage + ' ' : ''}finished · ${since(s.last_sample_at)} ago`
135 } else if (['running', 'recovering'].includes(phase)) {
136 tail = `ETA ${fmtDuration(s.eta_s)}`
137 if (s.lost_s >= 1) tail += ` (+${fmtDuration(s.lost_s)})`
138 if (phase === 'recovering' && peak && step !== null && peak > step) tail += ` · peak ${pct(peak, total)}%`
139 } else if (phase === 'stalled') {
140 tail = `stalled ${since(s.last_sample_at)}` + (cause ? ` · ${cause.slice(0, 50)}` : '')
141 } else if (phase === 'waiting') {
142 tail = `failed · waiting for relaunch ${since(s.issue_since)}` + (cause ? ` · ${cause.slice(0, 40)}` : '')
143 } else if (phase === 'done') {
144 tail = `done in ${fmtDuration((s.finished_at || now) - (s.started_at || now))}`
145 if (s.setbacks.length) tail += ` · setbacks cost ${fmtDuration(s.lost_s)}`
146 } else {
147 tail = phase
148 }
149 if (s.cost !== null && s.cost !== undefined && phase !== 'pending') tail += ` · $${s.cost.toFixed(2)}`
150 const stale = isStale(s, now)
151 return { text: `${head} ${tail}` + (stale ? ' · stale (poller not running?)' : ''), isStale: stale }
152}
153
154/** A running run with stages: a header row with the whole-job bar, then one row per stage of the
155 * current attempt. Anything else, or `expand` false, is the single summary line. */
156export function renderRows(s: RunState, now: number, expand = true): Row[] {
157 const attempt = s.attempt_no || 1
158 const stages = (s.stages ?? []).filter(st => (st.attempt ?? 1) === attempt)
159 if (!expand || !stages.length || isFinal(s)) return [renderLine(s, now)]
160 const since = (t: number | null) => (t ? fmtDuration(now - t) : '…')
161 const { phase } = s
162 let head = `${ICONS[phase] ?? '?'} ${s.run}`
163 const frac = jobFraction(s)
164 if (frac !== null) {
165 const done = roundHalfEven(frac * 1000)
166 head += ` ${bar(done, done, 1000)} ${pad3(Math.floor(frac * 100))}%`
167 }
168 const info: string[] = []
169 if (s.stage_count) info.push(`stage ${s.stage_index}/${s.stage_count}`)
170 if (attempt > 1) info.push(`attempt ${attempt}`)
171 if (s.setbacks.length) info.push(`↺${s.setbacks.length}` + (s.lost_s >= 1 ? ` (+${fmtDuration(s.lost_s)})` : ''))
172 const cause = s.last_error
173 if (phase === 'pending') info.push(`${s.pending_note || 'waiting for machine'} ${since(s.pending_since)}`)
174 else if (phase === 'stalled') info.push(`stalled ${since(s.last_sample_at)}` + (cause ? ` · ${cause.slice(0, 50)}` : ''))
175 else if (phase === 'waiting')
176 info.push(`failed · waiting for relaunch ${since(s.issue_since)}` + (cause ? ` · ${cause.slice(0, 40)}` : ''))
177 if (s.cost !== null && s.cost !== undefined) info.push(`$${s.cost.toFixed(2)}`)
178 const stale = isStale(s, now)
179 const rows: Row[] = [
180 { text: head + (info.length ? ' ' + info.join(' · ') : '') + (stale ? ' · stale (poller not running?)' : ''), isStale: stale },
181 ]
182
183 const width = Math.max(...stages.map(st => st.name.length))
184 for (const st of stages) {
185 const name = st.name.padEnd(width)
186 if (st.end !== null) {
187 rows.push({ text: ` ✔ ${name} ${fmtDuration(st.end - st.start)}`, isStale: false })
188 continue
189 }
190 let row = ` ▸ ${name}`
191 const { step, total } = s
192 if (total && st.name === s.stage) {
193 const peak = peakOf(s)
194 row += ` ${bar(step || 0, peak || step || 0, total)} ${pad3(pct(step, total))}%`
195 if (step !== null && step >= total) {
196 row += ` finished · ${since(s.last_sample_at)} ago`
197 } else {
198 row += ` ETA ${fmtDuration(s.eta_s)}`
199 if (peak && step !== null && peak > step) row += ` · peak ${pct(peak, total)}%`
200 }
201 } else {
202 // no bar until the stage's first reading; say so, so an empty row doesn't look broken
203 row += ` no progress reported yet · ${since(st.start)}`
204 }
205 rows.push({ text: row, isStale: false })
206 }
207 if (s.stage_count && stages.length < s.stage_count) {
208 const left = s.stage_count - Math.max(stages.length, s.stage_index || 0)
209 if (left > 0) rows.push({ text: ` · ${left} more stage${left > 1 ? 's' : ''}`, isStale: false })
210 }
211 return rows
212}
213
214/** Finished runs linger for a while; live ones show while their poller keeps the state fresh. The
215 * status line also keeps a quiet run whose poller is alive; the mod can't check a pid, and a live
216 * poller rewrites its state every few seconds anyway. */
217export function isShown(s: RunState, now: number): boolean {
218 if (isFinal(s)) return now - (s.finished_at || 0) < FINAL_VISIBLE_FOR
219 return now - (s.updated_at || 0) < FINAL_VISIBLE_FOR
220}
221
222/** Live runs above finished ones, then oldest start first, then by name. */
223export function byDisplayOrder(a: RunState, b: RunState): number {
224 const key = (s: RunState) => [isFinal(s) ? 1 : 0, s.started_at ?? Infinity] as const
225 const [fa, sa] = key(a)
226 const [fb, sb] = key(b)
227 return fa - fb || (sa === sb ? 0 : sa < sb ? -1 : 1) || a.run.localeCompare(b.run)
228}
229
230/** The rows for every shown run: the first two expanded, the rest one line each. */
231export function renderAll(states: RunState[], now: number): Row[] {
232 const visible = states.filter(s => isShown(s, now)).sort(byDisplayOrder)
233 const rows: Row[] = []
234 visible.slice(0, 5).forEach((s, i) => {
235 let out = renderRows(s, now, i < 2)
236 if (rows.length && rows.length + out.length > MAX_ROWS) out = [renderLine(s, now)]
237 rows.push(...out)
238 })
239 return rows.slice(0, MAX_ROWS)
240}
241types/index.d.ts 52 lines1// The job-progress mod's state, which the host keeps across hot reloads.
2
3/** One line of the band above the prompt, as the terminal draws it. */
4export type BandRow = { text: string; isStale: boolean }
5
6/** A poller the mod started: enough to start it again after a reload killed it. */
7export type Watcher = { args: string[] }
8
9/** One segment of a run's stage timeline on the desktop app. */
10export type CardStage = {
11 name: string
12 state: 'done' | 'active' | 'todo'
13 /** How long the stage took, or has run so far; null for one not started. */
14 seconds: number | null
15 /** The active stage's own progress, 0-1; null before its first reading. */
16 fraction: number | null
17}
18
19/** One run as the desktop app draws it: the facts the bar and its buttons need. */
20export type RunCard = {
21 run: string
22 icon: string
23 phase: string
24 isFinal: boolean
25 isStale: boolean
26 /** The whole run's progress, 0-1; null when nothing has reported a total yet. */
27 fraction: number | null
28 /** The furthest the run got before a setback, 0-1; null without one. */
29 peak: number | null
30 /** The stage timeline; empty for a run that declares no stages. */
31 stages: CardStage[]
32 /** What the run is doing, e.g. `45% · ETA 12m05s` or `waiting for machine · 5m17s`. */
33 headline: string
34 /** A second line: the current stage, or why it stalled or failed. */
35 detail: string | null
36 cost: string | null
37 /** The Lightning job behind the run, for its buttons; null for a Studio log. */
38 job: { name: string; teamspace: string | null } | null
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 lightning: {
44 rows: BandRow[]
45 cards: RunCard[]
46 /** Shown runs past the cards' limit, counted on a line below them. */
47 moreCards: number
48 watchers: Record<string, Watcher>
49 }
50 }
51}
52