SLOPSHOPPER

lights-out

Shows the background tasks this session left running and how much memory is free. Stops only what it started.

newpanebandcommandtoastprocess
v0.1.0MITupdated 2026-10-04ivanvyd/ground-rules/plugins/lights-out
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lights-out
│ ┃ lights-out ✕ › fix the failing auth test and add an audit log call │ ┃ No background tasks running. │ ┃ Free memory: not available here. ⏺ Read(src/auth.ts) │ ┃ Stop uses Claude Code's TaskStop. Child ⎿ Read 6 lines │ ┃ processes of a package-manager script can ⏺ Update(src/auth.ts) │ ┃ outlive it on Windows. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lights-out │ ⎿ lights-out: lights-out pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · lights-out
No background tasks running. Free memory: not available here. Stop uses Claude Code's TaskStop. Child processes of a package-manager script can outlive it on Windows.
README

lights-out

Shows the background tasks this session left running and how much memory is free. Stops one when you ask, and only through Claude Code's own TaskStop.

lights-out · next dev 47m · vite 12m · free RAM 12%   1: lights-out

Background shell commands have no time limit since Claude Code 2.1.288, so a forgotten dev server runs until something else ends it. Two of them once grew to 14 GB and 36 GB on one laptop.

Install

claude plugin install lights-out@ground-rules

Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.

What it does

  • Records every background task Claude starts, from what the tools report: Bash and PowerShell with run_in_background (or auto-backgrounded after a timeout), Monitor, and Agent with run_in_background. Each row keeps Claude's own description, the kind, the start time and the subagent that started it. It never keeps the command line.
  • Reconciles at the end of every turn against the background_tasks Claude Code reports, so finished tasks drop out. A task that ends mid-turn stays on the list until the turn ends.
  • Shows one band row while something runs or free memory is low, and a pane (/lights-out) with every task, its age and a Stop button. Digit 1 opens the pane. It never stops anything by itself.
  • Warns with one toast per crossing: a task older than 30 minutes, or free memory at or under 15% (physical or commit charge, whichever is lower). The memory warning arms again once memory climbs 5 points above the limit.
  • Reports tasks an earlier session still had running when it ended. The earlier ledger is read once and removed.

Stop

Stop asks first: Stop "next dev" (task b7, running 47m)?. On Stop the mod calls TaskStop with the task id. It sets no consent, so the person's own Claude Code permissions apply. Three seconds later it reads free memory again and shows Free RAM 12% → 14%.

TaskStop and package-manager scripts on Windows

In three of three spike runs on Windows (Claude Code 2.1.289, Git Bash), TaskStop on a pnpm dev task left the node server and its worker running. In the one run that also stopped a python -m http.server and a plain node script, both of those ended. The pnpm shim detaches from the process tree, so no chain of parent ids from Claude Code reaches it. That is why the mod reports free memory before and after instead of claiming a stop worked, and why it does not try to attribute memory to a task. If your free memory does not rise after a Stop, look for the server in Task Manager.

Settings

OptionDefault
warnAgeMinutes30Toast once when a task has run this long.
warnFreePercent15Toast once and show the band when free memory is at or under this.

Set them in /config, or in your user settings (or --settings) under pluginConfigs["lights-out@ground-rules"].options, with numbers, not strings. Claude Code does not read pluginConfigs from a project's .claude/settings.json, and a plugin loaded with --plugin-dir takes its options from pluginConfigs["lights-out"]. The mod reads them when it loads, so run /reload-plugins after a change. On 2.1.289 a warnAgeMinutes of 1 in --settings gave an age toast after a minute, while the same value in a project's settings file left the mod on its defaults.

What it reads, runs and stores

Hookssession.start, classic.PostToolUse, classic.Stop, classic.SubagentStop, session.end, command.run, and ui.render for the band and the pane.
Readstool_input.description, tool_response ids, and background_tasks. The first word of the command when Claude gave no description.
RunsOne short command every 30 seconds while a task is live: Get-CimInstance Win32_OperatingSystem on Windows, /proc/meminfo on Linux, vm_stat on macOS. It reads memory totals only.
Storesledger:<session>: the task rows and two timestamps. Only the session that owns a key writes it.
NeverA process list or command line, taskkill, kill, a port, an image name, consent.

One scan took about 0.4 s of wall time on the author's Windows laptop. On the desktop app $.process.run is not available, so the pane says memory is not available there; that case is untested.

Limits

  • Child processes of a package-manager script can outlive TaskStop on Windows (above).
  • Start-Process, nohup and Docker leave the task's process tree. The mod sees the task, not the container.
  • Memory is machine-wide, not per task.
  • A laptop that sleeps ages a task by wall time.
  • It is a convenience, not a guard.
Source 7 files
hooks/register.tsx 198 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Task } from '../types'
5import { bandText, formatAge, stopOutcome, stopQuestion, taskLabel } from './format'
6import { inFlightIds, leftRunning, record, running, settle, stop, taskFromToolUse, toLedger } from './ledger'
7import { lowestFree, parseMemory, scanArgv } from './memory'
8import { isPresentation } from './privacy'
9import { crossings } from './warn'
10
11const PANE = 'lights-out'
12const BACKGROUND_TOOLS = ['Bash', 'PowerShell', 'Monitor', 'Agent']
13const TICK_MS = 30_000
14const STOP_RESCAN_MS = 3_000
15
16const tasks = atom({ plugin: 'lights-out', key: 'tasks' } as const, [])
17const freePercent = atom({ plugin: 'lights-out', key: 'freePercent' } as const, null)
18const fired = atom({ plugin: 'lights-out', key: 'fired' } as const, [])
19const outcome = atom({ plugin: 'lights-out', key: 'outcome' } as const, '')
20
21/** Writes this session's ledger. Only this session ever writes its own key. */
22async function save($: EngineInterface, isEnded = false): Promise<void> {
23  const now = await $.clock.now()
24  await $.store.set(`ledger:${await $.session.id()}`, {
25    tasks: await read($, tasks),
26    updatedAt: now,
27    ...(isEnded ? { endedAt: now } : {}),
28  })
29}
30
31/** Free memory from one scan command, or undefined where it can't be read. */
32async function freeMemory($: EngineInterface, isWindows: boolean): Promise<number | undefined> {
33  const result = await $.process.run(scanArgv(isWindows), { timeoutMs: 10_000 }).catch(() => undefined)
34  const memory = result === undefined ? undefined : parseMemory(result.stdout, isWindows)
35  return memory === undefined ? undefined : lowestFree(memory)
36}
37
38export const register: Register = (on, options) => {
39  const limits = {
40    warnAgeMinutes: Number(options.warnAgeMinutes ?? 30),
41    warnFreePercent: Number(options.warnFreePercent ?? 15),
42  }
43  let isWindows = false
44
45  on('session.start', async ($, e, next) => {
46    isWindows = (await $.env.get('OS')) === 'Windows_NT'
47    const own = `ledger:${await $.session.id()}`
48    await $.command.register({
49      name: 'lights-out',
50      description: 'Show the background tasks this session left running',
51      immediate: true,
52    })
53
54    // Report what an earlier session still had running when it ended, once.
55    for (const key of (await $.store.keys()).filter(name => name.startsWith('ledger:') && name !== own)) {
56      const ledger = toLedger(await $.store.get(key))
57      if (ledger?.endedAt === undefined) continue
58
59      const left = leftRunning(ledger)
60      if (left.length > 0) {
61        const names = left.map(task => `"${taskLabel(task, false)}"`).join(', ')
62        $.ui.toast(`lights-out: an earlier session ended with ${names} running. They may still be running. /lights-out`)
63      }
64      await $.store.delete(key)
65    }
66
67    $.clock.every(TICK_MS, async () => {
68      const live = running(await read($, tasks))
69      if (live.length === 0) return
70
71      const free = await freeMemory($, isWindows)
72      await update($, freePercent, () => free ?? null)
73      const warned = crossings({
74        tasks: live,
75        now: await $.clock.now(),
76        freePercent: free,
77        limits,
78        fired: await read($, fired),
79        isPresenting: isPresentation(await $.env.get('GROUND_RULES_PRESENTATION')),
80      })
81      await update($, fired, () => warned.fired)
82      for (const toast of warned.toasts) $.ui.toast(toast)
83    })
84
85    return next(e)
86  })
87
88  on('classic.PostToolUse', async ($, e, next) => {
89    const task = BACKGROUND_TOOLS.includes(e.tool_name)
90      ? taskFromToolUse(e.tool_name, e.tool_input, e.tool_response, await $.clock.now(), e.agent_id)
91      : undefined
92
93    if (task) {
94      await update($, tasks, list => record(list, task))
95      await save($)
96    }
97    return next(e)
98  })
99
100  on('classic.Stop', async ($, e, next) => {
101    await update($, tasks, list => settle(list, inFlightIds(e.background_tasks)))
102    await save($)
103    return next(e)
104  })
105
106  on('classic.SubagentStop', async ($, e, next) => {
107    await update($, tasks, list => settle(list, inFlightIds(e.background_tasks)))
108    await save($)
109    return next(e)
110  })
111
112  on('session.end', async ($, e, next) => {
113    await save($, true)
114    return next(e)
115  })
116
117  on('command.run', { command: 'lights-out' }, async $ => {
118    await $.ui.open({ id: PANE, title: 'lights-out' })
119    return { text: 'lights-out pane opened.' }
120  })
121
122  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123    const below = await next(e)
124    if (e.props.hasSurvey) return below
125
126    const text = bandText(
127      {
128        tasks: await read($, tasks),
129        now: await $.clock.now(),
130        freePercent: (await read($, freePercent)) ?? undefined,
131        isPresenting: isPresentation(await $.env.get('GROUND_RULES_PRESENTATION')),
132      },
133      limits.warnFreePercent,
134    )
135    if (text === undefined) return below
136
137    const { Box, Button, Text } = $.ui.resolve(e)
138    return (
139      <Box flexDirection="column">
140        {below}
141        <Box>
142          <Text dimColor>{text} </Text>
143          <Button key="open" plain hotkey="1" label="lights-out" onPress={() => $.ui.open({ id: PANE, title: 'lights-out' })} />
144        </Box>
145      </Box>
146    )
147  })
148
149  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
150    const { Box, Button, Text } = $.ui.resolve(e)
151    const now = await $.clock.now()
152    const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
153    const list = await read($, tasks)
154    const free = await read($, freePercent)
155    const last = await read($, outcome)
156    const live = running(list)
157
158    const stopTask = async (task: Task): Promise<void> => {
159      const answer = await $.ui.ask(stopQuestion(task, await $.clock.now(), isPresenting), ['Keep', 'Stop'])
160      if (answer !== 'Stop') return
161
162      const before = (await read($, freePercent)) ?? undefined
163      const result = await $.tool.call({ tool: 'TaskStop', task_id: task.id })
164      if (result.deny !== undefined || result.isError === true) {
165        $.ui.toast(`lights-out: could not stop "${taskLabel(task, isPresenting)}".`)
166        return
167      }
168
169      await update($, tasks, current => stop(current, task.id))
170      await save($)
171      $.clock.after(STOP_RESCAN_MS, async () => {
172        const after = await freeMemory($, isWindows)
173        await update($, freePercent, () => after ?? null)
174        const message = stopOutcome(task, isPresenting, before, after)
175        await update($, outcome, () => message)
176        $.ui.toast(message)
177      })
178    }
179
180    return (
181      <Box flexDirection="column">
182        <Text>{live.length === 0 ? 'No background tasks running.' : `${live.length} running`}</Text>
183        {list.map(task => (
184          <Box key={`row-${task.id}`}>
185            <Text dimColor={!task.isRunning}>
186              {task.isRunning ? 'runs' : 'done'} {taskLabel(task, isPresenting)} · {task.kind} · {formatAge(now - task.startedAt)}{' '}
187            </Text>
188            {task.isRunning && <Button key={`stop-${task.id}`} label="Stop" onPress={() => stopTask(task)} />}
189          </Box>
190        ))}
191        <Text>{free === null ? 'Free memory: not available here.' : `Free RAM: ${free}%`}</Text>
192        {last !== '' && <Text dimColor>{last}</Text>}
193        <Text dimColor>Stop uses Claude Code's TaskStop. Child processes of a package-manager script can outlive it on Windows.</Text>
194      </Box>
195    )
196  })
197}
198
hooks/format.ts 50 lines
1import { running, type Task } from './ledger'
2import { label } from './privacy'
3
4const MINUTE = 60_000
5const HOUR = 60 * MINUTE
6const BAND_TASKS = 2
7
8/** `45s`, `47m`, `2h05m`. */
9export function formatAge(ms: number): string {
10  if (ms < MINUTE) return `${Math.max(0, Math.floor(ms / 1000))}s`
11  if (ms < HOUR) return `${Math.floor(ms / MINUTE)}m`
12  return `${Math.floor(ms / HOUR)}h${String(Math.floor((ms % HOUR) / MINUTE)).padStart(2, '0')}m`
13}
14
15/** One task as the band and the pane name it. */
16export const taskLabel = (task: Task, isPresenting: boolean): string => label(task.description, task.kind, isPresenting)
17
18export type BandInput = {
19  tasks: readonly Task[]
20  now: number
21  /** Free memory, 0 to 100, or undefined before the first scan or where none can be read. */
22  freePercent: number | undefined
23  isPresenting: boolean
24}
25
26/** The band's text, or undefined when there is nothing to show. */
27export function bandText({ tasks, now, freePercent, isPresenting }: BandInput, warnFreePercent: number): string | undefined {
28  const live = running(tasks)
29  const isLow = freePercent !== undefined && freePercent <= warnFreePercent
30  if (live.length === 0 && !isLow) return undefined
31
32  const parts = live
33    .slice(0, BAND_TASKS)
34    .map(task => `${taskLabel(task, isPresenting)} ${formatAge(now - task.startedAt)}`)
35  if (live.length > BAND_TASKS) parts.push(`+${live.length - BAND_TASKS} more`)
36  if (freePercent !== undefined) parts.push(`free RAM ${freePercent}%`)
37  return `lights-out · ${parts.join(' · ')}`
38}
39
40/** The question the Stop button asks. */
41export const stopQuestion = (task: Task, now: number, isPresenting: boolean): string =>
42  `Stop "${taskLabel(task, isPresenting)}" (task ${task.id}, running ${formatAge(now - task.startedAt)})?`
43
44/** What the toast says after a stop, from the free memory before and after. */
45export function stopOutcome(task: Task, isPresenting: boolean, before?: number, after?: number): string {
46  const name = taskLabel(task, isPresenting)
47  const memory = before !== undefined && after !== undefined ? ` Free RAM ${before}% → ${after}%.` : ''
48  return `lights-out: stopped "${name}".${memory}`
49}
50
hooks/ledger.ts 97 lines
1// The ledger of background tasks this session started, kept from what the
2// tools themselves report. Nothing here reads a process.
3
4import type { Task } from '../types'
5
6export type { Task, TaskKind } from '../types'
7
8/** The ledger as `$.store` keeps it: written only by the session it belongs to. */
9export type Ledger = {
10  tasks: Task[]
11  updatedAt: number
12  /** Set by `session.end`; a ledger with this is finished and may be reported once. */
13  endedAt?: number
14}
15
16const KEPT_FINISHED = 20
17
18const asRecord = (value: unknown): Record<string, unknown> =>
19  typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
20
21const text = (value: unknown): string | undefined =>
22  typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
23
24/** The first word of a command, which is enough to label a task and never carries an argument. */
25const firstWord = (command: unknown): string | undefined => text(command)?.split(/\s+/)[0]
26
27/**
28 * The task a finished tool call started in the background, or undefined when
29 * the call ran in the foreground. Reads the three shapes the tools report:
30 * `backgroundTaskId` (Bash, PowerShell), `taskId` (Monitor) and an async
31 * launch with an `agentId` (Agent).
32 */
33export function taskFromToolUse(
34  tool: string,
35  input: unknown,
36  response: unknown,
37  now: number,
38  agentId?: string,
39): Task | undefined {
40  const given = asRecord(input)
41  const result = asRecord(response)
42  const base = { startedAt: now, isRunning: true, ...(agentId === undefined ? {} : { agentId }) }
43
44  switch (tool) {
45    case 'Bash':
46    case 'PowerShell': {
47      const id = text(result.backgroundTaskId)
48      if (id === undefined) return undefined
49      return { ...base, id, kind: 'shell', description: text(given.description) ?? firstWord(given.command) ?? 'shell task' }
50    }
51    case 'Monitor': {
52      const id = text(result.taskId)
53      return id === undefined ? undefined : { ...base, id, kind: 'monitor', description: text(given.description) ?? 'monitor' }
54    }
55    case 'Agent': {
56      const id = text(result.agentId)
57      if (result.isAsync !== true || id === undefined) return undefined
58      return { ...base, id, kind: 'agent', description: text(given.description) ?? 'subagent' }
59    }
60    default:
61      return undefined
62  }
63}
64
65/** Adds a task, keeping a recorded one as it was and a short tail of finished ones. */
66export function record(tasks: readonly Task[], task: Task): Task[] {
67  if (tasks.some(known => known.id === task.id)) return [...tasks]
68
69  const finished = tasks.filter(known => !known.isRunning).slice(-KEPT_FINISHED)
70  return [...finished, ...tasks.filter(known => known.isRunning), task]
71}
72
73/** Marks every running task that is no longer in flight as finished. */
74export function settle(tasks: readonly Task[], inFlight: ReadonlySet<string>): Task[] {
75  return tasks.map(task => (task.isRunning && !inFlight.has(task.id) ? { ...task, isRunning: false } : task))
76}
77
78/** The ids in a Stop event's `background_tasks`. */
79export const inFlightIds = (backgroundTasks: readonly { id: string }[] | undefined): Set<string> =>
80  new Set((backgroundTasks ?? []).map(task => task.id))
81
82export const running = (tasks: readonly Task[]): Task[] => tasks.filter(task => task.isRunning)
83
84export const stop = (tasks: readonly Task[], id: string): Task[] =>
85  tasks.map(task => (task.id === id ? { ...task, isRunning: false } : task))
86
87/** What a ledger written by another session read as: a stored value of unknown shape. */
88export function toLedger(stored: unknown): Ledger | undefined {
89  const value = asRecord(stored)
90  if (!Array.isArray(value.tasks) || typeof value.updatedAt !== 'number') return undefined
91  return value as Ledger
92}
93
94/** The tasks an earlier session left running when it ended. */
95export const leftRunning = (ledger: Ledger): Task[] =>
96  ledger.endedAt === undefined ? [] : running(ledger.tasks)
97
hooks/memory.ts 91 lines
1// Free machine memory, read with one short host command per scan. The scan
2// asks for memory totals only: no process list, no command lines, no paths.
3
4export type Memory = {
5  /** Free physical memory, 0 to 100. */
6  physicalFreePercent: number
7  /** Free commit charge (Windows only), 0 to 100. */
8  commitFreePercent?: number
9}
10
11const percent = (free: number, total: number): number | undefined =>
12  total > 0 ? Math.round((free / total) * 100) : undefined
13
14const WINDOWS_SCRIPT =
15  'Get-CimInstance Win32_OperatingSystem -Property FreePhysicalMemory,TotalVisibleMemorySize,FreeVirtualMemory,TotalVirtualMemorySize | ' +
16  'Select-Object FreePhysicalMemory,TotalVisibleMemorySize,FreeVirtualMemory,TotalVirtualMemorySize | ConvertTo-Json -Compress'
17
18const UNIX_SCRIPT =
19  'if [ -r /proc/meminfo ]; then grep -E "^(MemTotal|MemAvailable):" /proc/meminfo; ' +
20  'else echo "hw.memsize=$(sysctl -n hw.memsize)"; vm_stat; fi'
21
22export const scanArgv = (isWindows: boolean): string[] =>
23  isWindows
24    ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_SCRIPT]
25    : ['sh', '-c', UNIX_SCRIPT]
26
27type WindowsMemory = {
28  FreePhysicalMemory: number
29  TotalVisibleMemorySize: number
30  FreeVirtualMemory: number
31  TotalVirtualMemorySize: number
32}
33
34const WINDOWS_FIELDS = ['FreePhysicalMemory', 'TotalVisibleMemorySize', 'FreeVirtualMemory', 'TotalVirtualMemorySize'] as const
35
36function isWindowsMemory(value: unknown): value is WindowsMemory {
37  return typeof value === 'object' && value !== null && WINDOWS_FIELDS.every(field => typeof Reflect.get(value, field) === 'number')
38}
39
40function parseWindows(stdout: string): Memory | undefined {
41  const start = stdout.indexOf('{')
42  if (start === -1) return undefined
43
44  let os: unknown
45  try {
46    os = JSON.parse(stdout.slice(start))
47  } catch {
48    return undefined
49  }
50  if (!isWindowsMemory(os)) return undefined
51
52  const physical = percent(os.FreePhysicalMemory, os.TotalVisibleMemorySize)
53  const commit = percent(os.FreeVirtualMemory, os.TotalVirtualMemorySize)
54  if (physical === undefined) return undefined
55  return commit === undefined
56    ? { physicalFreePercent: physical }
57    : { physicalFreePercent: physical, commitFreePercent: commit }
58}
59
60function parseVmStat(stdout: string, totalBytes: number): Memory | undefined {
61  const pageSize = Number(/page size of (\d+) bytes/.exec(stdout)?.[1])
62  const pages = (label: string): number =>
63    Number(new RegExp(`^${label}:\\s+(\\d+)\\.`, 'm').exec(stdout)?.[1] ?? 0)
64  if (!pageSize) return undefined
65
66  // macOS counts inactive and speculative pages as available.
67  const freePages = pages('Pages free') + pages('Pages inactive') + pages('Pages speculative')
68  const free = percent(freePages * pageSize, totalBytes)
69  return free === undefined ? undefined : { physicalFreePercent: free }
70}
71
72function parseUnix(stdout: string): Memory | undefined {
73  const total = Number(/^MemTotal:\s+(\d+) kB/m.exec(stdout)?.[1])
74  const available = Number(/^MemAvailable:\s+(\d+) kB/m.exec(stdout)?.[1])
75  if (total && available) {
76    const free = percent(available, total)
77    return free === undefined ? undefined : { physicalFreePercent: free }
78  }
79
80  const memsize = Number(/^hw\.memsize=(\d+)/m.exec(stdout)?.[1])
81  return memsize ? parseVmStat(stdout, memsize) : undefined
82}
83
84/** Memory from the output of the scan command, or undefined when it isn't recognisable. */
85export const parseMemory = (stdout: string, isWindows: boolean): Memory | undefined =>
86  isWindows ? parseWindows(stdout) : parseUnix(stdout)
87
88/** The lower of the physical and commit figures: the one closer to trouble. */
89export const lowestFree = (memory: Memory): number =>
90  Math.min(memory.physicalFreePercent, memory.commitFreePercent ?? 100)
91
hooks/privacy.ts 28 lines
1// What may reach the screen. Descriptions are Claude's own words about a
2// command and can carry a client name, a path or a token, so every one is cut
3// and scrubbed, and `GROUND_RULES_PRESENTATION=1` replaces them with the kind.
4
5const LABEL_LIMIT = 28
6
7/** Long runs of token-like characters and URL credentials become an ellipsis. */
8export function scrub(text: string): string {
9  return text
10    .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s/@]*@/gi, '')
11    .replace(/[A-Za-z0-9_\-+/=]{24,}/g, '…')
12    .replace(/\b[A-Za-z]:[\\/][^\s"']*/g, '…')
13    .replace(/(^|\s)\/[^\s"']+/g, '$1…')
14    .replace(/\s+/g, ' ')
15    .trim()
16}
17
18/** A task description as it may appear on screen. */
19export function label(description: string, kind: string, isPresenting: boolean): string {
20  if (isPresenting) return kind
21  const clean = scrub(description)
22  if (clean === '') return kind
23  return clean.length > LABEL_LIMIT ? `${clean.slice(0, LABEL_LIMIT - 1).trimEnd()}…` : clean
24}
25
26/** True when the presentation switch is set to the literal `1`. */
27export const isPresentation = (value: string | undefined): boolean => value === '1'
28
hooks/warn.ts 53 lines
1import { formatAge, taskLabel } from './format'
2import { running, type Task } from './ledger'
3
4export type Limits = {
5  /** A background task older than this, in minutes, gets one toast. */
6  warnAgeMinutes: number
7  /** Free memory at or below this percent gets one toast. */
8  warnFreePercent: number
9}
10
11export type WarnInput = {
12  tasks: readonly Task[]
13  now: number
14  freePercent: number | undefined
15  limits: Limits
16  /** The warnings already shown: a task id for age, `free` for memory. */
17  fired: readonly string[]
18  isPresenting: boolean
19}
20
21export type WarnResult = {
22  toasts: string[]
23  fired: string[]
24}
25
26/** Free memory must climb this far above the limit before the warning arms again. */
27const REARM_MARGIN = 5
28
29/** The toasts due now, once per crossing, and the updated set of warnings already shown. */
30export function crossings({ tasks, now, freePercent, limits, fired, isPresenting }: WarnInput): WarnResult {
31  const shown = new Set(fired)
32  const toasts: string[] = []
33
34  for (const task of running(tasks)) {
35    const age = now - task.startedAt
36    if (age >= limits.warnAgeMinutes * 60_000 && !shown.has(task.id)) {
37      shown.add(task.id)
38      toasts.push(`lights-out: "${taskLabel(task, isPresenting)}" has run for ${formatAge(age)}. Still needed? /lights-out`)
39    }
40  }
41
42  if (freePercent !== undefined) {
43    if (freePercent <= limits.warnFreePercent && !shown.has('free')) {
44      shown.add('free')
45      toasts.push(`lights-out: free RAM is ${freePercent}%. /lights-out`)
46    } else if (freePercent > limits.warnFreePercent + REARM_MARGIN) {
47      shown.delete('free')
48    }
49  }
50
51  return { toasts, fired: [...shown] }
52}
53
types/index.d.ts 28 lines
1export type TaskKind = 'shell' | 'monitor' | 'agent'
2
3export type Task = {
4  /** The id TaskStop takes. */
5  id: string
6  kind: TaskKind
7  /** Claude's own description of the task, or a plain label when it gave none. */
8  description: string
9  startedAt: number
10  isRunning: boolean
11  /** The subagent that started it, when one did. */
12  agentId?: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'lights-out': {
18      tasks: Task[]
19      /** Free memory at the last scan, 0 to 100; null before a scan or where none can be read. */
20      freePercent: number | null
21      /** Warnings already shown: a task id for age, `free` for memory. */
22      fired: string[]
23      /** The last thing a Stop did, for the pane. */
24      outcome: string
25    }
26  }
27}
28