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

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.
claude plugin install lights-out@ground-rules
Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.
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.background_tasks Claude Code reports, so finished tasks drop out. A task that ends mid-turn stays on the list until the turn ends./lights-out) with every task, its age and a Stop button. Digit 1 opens the pane. It never stops anything by itself.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%.
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.
| Option | Default | |
|---|---|---|
warnAgeMinutes | 30 | Toast once when a task has run this long. |
warnFreePercent | 15 | Toast 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.
| Hooks | session.start, classic.PostToolUse, classic.Stop, classic.SubagentStop, session.end, command.run, and ui.render for the band and the pane. |
| Reads | tool_input.description, tool_response ids, and background_tasks. The first word of the command when Claude gave no description. |
| Runs | One 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. |
| Stores | ledger:<session>: the task rows and two timestamps. Only the session that owns a key writes it. |
| Never | A 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.
TaskStop on Windows (above).Start-Process, nohup and Docker leave the task's process tree. The mod sees the task, not the container.hooks/register.tsx 198 lines1import { 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}
198hooks/format.ts 50 lines1import { 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}
50hooks/ledger.ts 97 lines1// 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)
97hooks/memory.ts 91 lines1// 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)
91hooks/privacy.ts 28 lines1// 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'
28hooks/warn.ts 53 lines1import { 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}
53types/index.d.ts 28 lines1export 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