SLOPSHOPPER

Lightning AI

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

newbandguardtoastprompttool
★ 9v1.1.1Apache-2.0updated 2026-10-08Lightning-AI/skills
A shopper browsing a rack in a slop shop
README

Lightning AI Agent Skills

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.

Skills

SkillWhat it covers
lightning-studiosCreate, start, stop and manage cloud GPU Studios; switch machines, run commands, transfer files, SSH
lightning-jobsLaunch 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-deploymentsDeploy containers/APIs with autoscaling, manage releases, endpoints and auth
lightning-sandboxesFast ephemeral VMs for safe code execution: run commands, background processes, file I/O, Docker (docker / docker compose) and public port URLs
lightning-llm-gatewayCall hosted LLMs (OpenAI, Anthropic, open models) through Lightning's models API
lightning-artifactsPublish 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-estimationQuote 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.

Install

Claude Code plugin

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.

skills.sh (Claude Code, Cursor, Codex and others)

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.

Manual copy

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/

Prerequisites

  • Python 3.11+ with 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)
  • A Lightning AI account. Sign in once, where the agent runs:
  • On your machine: lightning login (browser sign-in, saved for later commands)
  • Headless (CI, cloud agents, no browser): set LIGHTNING_API_KEY (plus LIGHTNING_USER_ID, optional) through that environment's secrets
  • Inside a Studio: nothing to do, you're already signed in

[!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.

Conventions

  • Skills never guess the organization or teamspace: when more than one is available and none is configured, they ask the user which one to use.
  • Skills prefer the documented SDK/CLI surface and fall back to lightning api <endpoint> for raw REST calls.

Contributing

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.

Support

License

Apache-2.0. By contributing you agree your contributions are licensed the same way, and to follow the code of conduct.

Source 5 files
hooks/job-progress/register.tsx 550 lines
1// 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}
550
hooks/job-progress/delivery.ts 70 lines
1// 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}
70
hooks/job-progress/desktop.ts 203 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
203
hooks/job-progress/render.ts 241 lines
1// 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}
241
types/index.d.ts 52 lines
1// 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