SLOPSHOPPER

wake-me

Claude ends its turn instead of polling. You watch the progress bar, and the mod wakes Claude when the file is ready.

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-04ivanvyd/ground-rules/plugins/wake-me
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wake-me
│ ┃ waits ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing is being watched. │ ┃ Watch a file: path of a file that is being w ⏺ Read(src/auth.ts) │ ┃ Your own watches end with a toast. Only ⎿ Read 6 lines │ ┃ Claude's wake Claude. ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /waits │ ⎿ wake-me: waits pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · waits
Nothing is being watched. Watch a file: path of a file that is being written Your own watches end with a toast. Only Claude's wake Claude.
README

wake-me

Claude ends its turn instead of polling. You watch the progress bar, and the mod wakes Claude when the file is ready.

⏰ #3 final_cut.mp4 ███████░░░ 68% · ETA 4m10s   2: waits

A render takes minutes. Without this, an agent runs sleep and until loops, one shell call after another, and every call adds to a context that may already be 900K tokens long.

When to use it, and when not

Skip it when Claude starts the job itself with run_in_background. Claude Code tells Claude when a background task ends, and that notice wakes it before this mod could. wake-me adds nothing there.

Use it for work Claude Code did not start:

  • A job you started outside the session, in another terminal or on another machine.
  • A file another tool writes, such as a render queue, a download, an export or a build server.
  • A render started elsewhere that Claude has to wait for before it can carry on.
  • Progress you want to watch yourself. The band shows percent and ETA when the job reports them.

Install

claude plugin install wake-me@ground-rules

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

How it works

The mod registers a tool, wait_for, which Claude sees as mcp__wake-me__wait_for. Claude calls it with a file to watch. The tool answers at once and tells Claude to end its turn. A timer checks the file every two seconds. When the watch finishes, the mod submits one prompt that wakes Claude:

wake-me: #3 final_cut.mp4 is ready (46.5 MB): the job reports it is finished. Carry on with what you were waiting for. Do not watch it again.

In spike runs on Windows, Claude ended its turn straight after the call 5 of 5 times when asked to use the tool. A woken session started exactly one turn, immediately when idle and after the running turn when busy. Whether Claude picks the tool unprompted was not tested.

Watch kinds

KindFinishes when
file-existsThe file exists.
file-stableSize and modification time have not changed for stableSeconds (default 15) and the file is not empty.
progressAn ffmpeg -progress file reports progress=end, or a JSON sidecar { "done": 40, "total": 100 } reaches its total. Pass totalSeconds for ffmpeg to get percent and ETA.
log-matchA short regular expression matches a line in the last 64 KiB of the file.

The timeout defaults to 120 minutes and tops out at 720. A timeout wakes Claude once with the last state it saw.

Prefer progress for ffmpeg output: +faststart rewrites an MP4 at the end, so file-stable can fire early. Use -stats_period 5 so the progress file stays small. The mod reads files up to 4 MiB. On a Windows spike, ffmpeg's output file changed size or modification time every second while it wrote, and its progress file stayed readable throughout.

Loop guards

  • At most 8 live watches.
  • The same path can be armed at most 6 times an hour.
  • A wake never arms anything.
  • The mod is the only thing here that submits a prompt on its own, and only from its timer. It never sets asUser, so Claude does not read a wake as your words.
  • A watch you add yourself in /waits ends with a toast and never starts a turn.

Band and pane

The band shows up to three watches, with a progress bar when the job reports one. Digit 2 opens /waits, where each watch has a Cancel button. Cancel never wakes Claude. A digit typed alone into an empty prompt fires the band's button, so nothing destructive sits on it.

Monitor

Claude Code's Monitor streams a command's output and needs a shell. wake-me watches files without one, wakes Claude once and shows you a bar. Use Monitor when you need the lines themselves.

What it reads, runs and stores

Hookssession.start, tool.call for its own tool only, command.run, and ui.render for the band and the pane.
ReadsThe watched files: existence, size, modification time, and text for progress and log-match.
RunsNothing. It uses the file API, so it works on the desktop app too.
StoresThe watches of this session, in Claude Code's session state. Nothing persists.
NeverA shell, the network, asUser.

Limits

  • On the mobile surface there is no text field, so you cannot add your own watch there.
  • Whether a half-typed prompt survives a wake was not tested; the headless spike cannot show it. If it does not, the wake waits for an idle session.
  • The tool is deferred behind ToolSearch by default, so Claude may spend a round trip finding it.
  • Times come from the clock, so a laptop that sleeps ages a watch by wall time.
Source 6 files
hooks/register.tsx 208 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { armedText, bandRows, nameOf, wakeText } from './format'
5import { isPresentation } from './privacy'
6import {
7  MAX_READ_BYTES,
8  admit,
9  checkRequest,
10  isLive,
11  needsText,
12  newWatch,
13  normalizePath,
14  step,
15  type Observation,
16  type Wake,
17} from './watch'
18import type { Watch } from '../types'
19
20const PANE = 'wake-me'
21const TOOL = 'mcp__wake-me__wait_for'
22const TICK_MS = 2_000
23const KEPT_ENDED = 5
24
25const DESCRIPTION =
26  'Watch a file instead of polling. Use it when a render, export or build will write a file, report progress to a file, or log a line you can name. ' +
27  'It returns at once. After calling it, END YOUR TURN: do not run sleep or until-loops and do not call other tools to wait. ' +
28  'A message will wake you when the watch finishes. Kinds: file-exists, file-stable (the file stops changing), progress (an ffmpeg -progress file or a JSON sidecar {"done","total"}), log-match (a regular expression over the last 64 KiB). ' +
29  'When woken, do not watch the same file again.'
30
31const SCHEMA = {
32  type: 'object',
33  properties: {
34    kind: { type: 'string', enum: ['file-exists', 'file-stable', 'progress', 'log-match'] },
35    path: { type: 'string', description: 'Absolute path of the file to watch.' },
36    label: { type: 'string', description: 'A short name for the person to see.' },
37    stableSeconds: { type: 'number', description: 'file-stable: seconds without a change. Default 15.' },
38    totalSeconds: { type: 'number', description: 'progress: length of the output in seconds, for percent and ETA.' },
39    pattern: { type: 'string', description: 'log-match: a short regular expression.' },
40    timeoutMinutes: { type: 'number', description: 'Give up after this long. Default 120, at most 720.' },
41  },
42  required: ['kind', 'path'],
43}
44
45const watches = atom({ plugin: 'wake-me', key: 'watches' } as const, [])
46const nextId = atom({ plugin: 'wake-me', key: 'nextId' } as const, 1)
47const armed = atom({ plugin: 'wake-me', key: 'armed' } as const, {})
48
49let timer: Timer | undefined
50let isTicking = false
51
52/** What one tick sees of the watched file; a locked or missing file reads as less, never as an error. */
53async function observe($: EngineInterface, watch: Watch): Promise<Observation> {
54  if (!(await $.fs.exists(watch.path))) return { exists: false }
55
56  const stat = await $.fs.stat(watch.path)
57  if (!needsText(watch) || stat.size > MAX_READ_BYTES) return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs }
58
59  const text = await $.fs.read(watch.path).catch(() => undefined)
60  return { exists: true, size: stat.size, mtimeMs: stat.mtimeMs, ...(text === undefined ? {} : { text }) }
61}
62
63/** Wakes Claude, or for a person's own watch only tells the person. Never awaited: a wake waits for an idle session. */
64async function deliver($: EngineInterface, watch: Watch, wake: Wake, now: number): Promise<void> {
65  const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
66  const text = wakeText(watch, wake, now, isPresenting)
67
68  if (watch.isOwn) {
69    $.ui.toast(text)
70    return
71  }
72  $.ui.toast(`wake-me: #${watch.id} ${nameOf(watch, isPresenting)} ${wake.reason === 'ready' ? 'is ready' : 'timed out'}`)
73  void $.prompt.submit({ text }).catch(() => $.ui.toast('wake-me: could not wake Claude. Ask it to check.'))
74}
75
76async function tick($: EngineInterface): Promise<void> {
77  if (isTicking) return
78  isTicking = true
79  try {
80    const live = (await read($, watches)).filter(isLive)
81    if (live.length === 0) {
82      timer?.cancel()
83      timer = undefined
84      return
85    }
86
87    const now = await $.clock.now()
88    const results = await Promise.all(live.map(async watch => step(watch, await observe($, watch), now)))
89    await update($, watches, list => list.map(known => results.find(result => result.watch.id === known.id)?.watch ?? known))
90    for (const { watch, wake } of results) {
91      if (wake) await deliver($, watch, wake, now)
92    }
93  } finally {
94    isTicking = false
95  }
96}
97
98/** Starts the one timer, if it isn't running. It stops itself when no watch is live. */
99function ensureTimer($: EngineInterface): void {
100  timer ??= $.clock.every(TICK_MS, () => void tick($))
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    await $.tool.register({ name: 'wait_for', description: DESCRIPTION, inputSchema: SCHEMA })
106    await $.command.register({ name: 'waits', description: 'Show the files wake-me is watching', immediate: true })
107    if ((await read($, watches)).some(isLive)) ensureTimer($)
108    return next(e)
109  })
110
111  on('tool.call', { tool: TOOL }, async ($, e) => {
112    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
113    const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
114    const checked = checkRequest(e, isWindows)
115    if (!checked.ok) return { isError: true, result: checked.message }
116
117    const now = await $.clock.now()
118    const admission = admit(await read($, watches), checked.spec.path, await read($, armed), now)
119    if (!admission.ok) return { isError: true, result: admission.message }
120
121    const id = await read($, nextId)
122    const watch = newWatch(id, checked.spec, now, false)
123    await update($, nextId, n => n + 1)
124    await update($, armed, () => admission.armed)
125    await update($, watches, list => [...list, watch])
126    ensureTimer($)
127    return { result: armedText(watch, isPresenting) }
128  })
129
130  on('command.run', { command: 'waits' }, async $ => {
131    await $.ui.open({ id: PANE, title: 'waits' })
132    return { text: 'waits pane opened.' }
133  })
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const below = await next(e)
137    if (e.props.hasSurvey) return below
138
139    const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
140    const rows = bandRows(await read($, watches), await $.clock.now(), isPresenting)
141    if (rows.length === 0) return below
142
143    const { Box, Button, Text } = $.ui.resolve(e)
144    return (
145      <Box flexDirection="column">
146        {below}
147        {rows.map((row, index) => (
148          <Box key={`row-${index}`}>
149            <Text dimColor>{row} </Text>
150            {index === 0 && (
151              <Button key="open" plain hotkey="2" label="waits" onPress={() => $.ui.open({ id: PANE, title: 'waits' })} />
152            )}
153          </Box>
154        ))}
155      </Box>
156    )
157  })
158
159  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
160    const elements = $.ui.resolve(e)
161    const { Box, Button, Text } = elements
162    // The mobile surface has no text field.
163    const Input = 'Input' in elements ? elements.Input : undefined
164    const now = await $.clock.now()
165    const isWindows = (await $.env.get('OS')) === 'Windows_NT'
166    const isPresenting = isPresentation(await $.env.get('GROUND_RULES_PRESENTATION'))
167    const all = await read($, watches)
168    const live = all.filter(isLive)
169    const ended = all.filter(watch => !isLive(watch)).slice(-KEPT_ENDED)
170
171    const cancel = (id: number) => async (): Promise<void> => {
172      await update($, watches, list => list.map(known => (known.id === id && isLive(known) ? { ...known, status: 'cancelled' } : known)))
173    }
174
175    const addOwn = async (path: string): Promise<void> => {
176      const checked = checkRequest({ kind: 'file-stable', path }, isWindows)
177      if (!checked.ok) {
178        $.ui.toast(checked.message)
179        return
180      }
181      const at = await $.clock.now()
182      const id = await read($, nextId)
183      await update($, nextId, n => n + 1)
184      await update($, watches, list => [...list, newWatch(id, checked.spec, at, true)])
185      ensureTimer($)
186    }
187
188    return (
189      <Box flexDirection="column">
190        {live.length === 0 && <Text dimColor>Nothing is being watched.</Text>}
191        {live.map(watch => (
192          <Box key={`live-${watch.id}`}>
193            <Text>{bandRows([watch], now, isPresenting)[0]} </Text>
194            <Button key={`cancel-${watch.id}`} label="Cancel" onPress={cancel(watch.id)} />
195          </Box>
196        ))}
197        {ended.map(watch => (
198          <Text key={`ended-${watch.id}`} dimColor>
199            #{watch.id} {nameOf(watch, isPresenting)} {watch.status}
200          </Text>
201        ))}
202        {Input && !isPresenting && <Input key="add" label="Watch a file" placeholder="path of a file that is being written" onSubmit={addOwn} />}
203        <Text dimColor>Your own watches end with a toast. Only Claude's wake Claude.</Text>
204      </Box>
205    )
206  })
207}
208
hooks/format.ts 62 lines
1import type { Watch } from '../types'
2import { scrub } from './privacy'
3import { isLive, type Wake } from './watch'
4
5const BAR_WIDTH = 10
6const BAND_ROWS = 3
7const MINUTE = 60
8
9/** `4m10s`, `45s`, `1h05m`. */
10export function formatSeconds(seconds: number): string {
11  if (seconds < MINUTE) return `${Math.max(0, Math.round(seconds))}s`
12  if (seconds < 60 * MINUTE) return `${Math.floor(seconds / MINUTE)}m${String(Math.round(seconds % MINUTE)).padStart(2, '0')}s`
13  return `${Math.floor(seconds / (60 * MINUTE))}h${String(Math.floor((seconds % (60 * MINUTE)) / MINUTE)).padStart(2, '0')}m`
14}
15
16export function formatBytes(bytes: number): string {
17  if (bytes < 1024) return `${bytes} B`
18  if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
19  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
20  return `${(bytes / 1024 ** 3).toFixed(1)} GB`
21}
22
23/** `███████░░░` for 68%. */
24export function bar(percent: number): string {
25  const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((percent / 100) * BAR_WIDTH)))
26  return '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
27}
28
29/** What the band and pane call a watch: the label cut and scrubbed, or its kind in presentation mode. */
30export const nameOf = (watch: Watch, isPresenting: boolean): string => (isPresenting ? watch.kind : scrub(watch.label) || watch.kind)
31
32/** One band row: `⏰ #3 final_cut.mp4 ███████░░ 68% · ETA 4m10s`. */
33export function bandRow(watch: Watch, now: number, isPresenting: boolean): string {
34  const name = `#${watch.id} ${nameOf(watch, isPresenting)}`
35  if (watch.percent === undefined) return `⏰ ${name} · ${formatSeconds((now - watch.createdAt) / 1000)}`
36
37  const eta = watch.etaSeconds === undefined ? '' : ` · ETA ${formatSeconds(watch.etaSeconds)}`
38  return `⏰ ${name} ${bar(watch.percent)} ${watch.percent}%${eta}`
39}
40
41/** The rows the band shows: the first live watches, and a count of the rest. */
42export function bandRows(watches: readonly Watch[], now: number, isPresenting: boolean): string[] {
43  const live = watches.filter(isLive)
44  const rows = live.slice(0, BAND_ROWS).map(watch => bandRow(watch, now, isPresenting))
45  return live.length > BAND_ROWS ? [...rows, `⏰ +${live.length - BAND_ROWS} more watches`] : rows
46}
47
48/** The message that wakes Claude, or that the person reads in a toast. */
49export function wakeText(watch: Watch, wake: Wake, now: number, isPresenting: boolean): string {
50  const name = nameOf(watch, isPresenting)
51  const size = watch.size === undefined ? '' : ` (${formatBytes(watch.size)})`
52  if (wake.reason === 'ready') {
53    return `wake-me: #${watch.id} ${name} is ready${size}: ${wake.detail}. Carry on with what you were waiting for. Do not watch it again.`
54  }
55  const waited = formatSeconds((now - watch.createdAt) / 1000)
56  return `wake-me: #${watch.id} ${name} timed out after ${waited}; ${wake.detail}. Check it yourself before deciding what to do.`
57}
58
59/** What the tool call answers, so Claude ends its turn. */
60export const armedText = (watch: Watch, isPresenting: boolean): string =>
61  `Watching ${nameOf(watch, isPresenting)} as #${watch.id} (${watch.kind}). End your turn now: do not poll or sleep. A message will wake you when it is ready.`
62
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/watch.ts 195 lines
1import type { Watch, WatchKind } from '../types'
2import { estimateSeconds, readProgress } from './progress'
3
4export const MAX_LIVE = 8
5export const MAX_REARMS_PER_HOUR = 6
6export const MAX_READ_BYTES = 4 * 1024 * 1024
7const DEFAULT_TIMEOUT_MINUTES = 120
8const MAX_TIMEOUT_MINUTES = 720
9const DEFAULT_STABLE_SECONDS = 15
10const LOG_TAIL_CHARS = 64 * 1024
11const MAX_PATTERN_LENGTH = 200
12const HOUR = 3_600_000
13const KINDS: readonly WatchKind[] = ['file-exists', 'file-stable', 'progress', 'log-match']
14
15/** What a watch needs to be created: the model's request, checked. */
16export type Spec = Pick<Watch, 'kind' | 'path' | 'label' | 'stableSeconds' | 'totalSeconds' | 'pattern'> & {
17  timeoutMinutes: number
18}
19
20export type Checked = { ok: true; spec: Spec } | { ok: false; message: string }
21
22const MSYS_DRIVE = /^\/([A-Za-z])\/(.*)$/
23
24/** A path as the file API takes it: Git Bash's `/d/x` becomes `D:/x` on Windows. */
25export function normalizePath(path: string, isWindows: boolean): string {
26  const msys = isWindows ? MSYS_DRIVE.exec(path) : null
27  return msys ? `${(msys[1] as string).toUpperCase()}:/${msys[2]}` : path
28}
29
30export const basename = (path: string): string => path.split(/[\\/]/).filter(part => part !== '').at(-1) ?? path
31
32const asRecord = (value: unknown): Record<string, unknown> =>
33  typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
34
35function positiveNumber(value: unknown): number | undefined {
36  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
37}
38
39/** A nested quantifier such as `(a+)+` can take exponential time on a long log. */
40const isRiskyPattern = (pattern: string): boolean => /\([^)]*[+*][^)]*\)[+*{]/.test(pattern)
41
42/** Checks a `wait_for` request and fills in the defaults. */
43export function checkRequest(input: unknown, isWindows: boolean): Checked {
44  const request = asRecord(input)
45  const kind = KINDS.find(known => known === request.kind)
46  const rawPath = typeof request.path === 'string' ? request.path.trim() : ''
47
48  if (!kind) return { ok: false, message: `wake-me: kind must be one of ${KINDS.join(', ')}.` }
49  if (rawPath === '') return { ok: false, message: 'wake-me: path is required.' }
50
51  const path = normalizePath(rawPath, isWindows)
52  const pattern = typeof request.pattern === 'string' ? request.pattern : undefined
53  if (kind === 'log-match') {
54    if (pattern === undefined || pattern === '') return { ok: false, message: 'wake-me: log-match needs a pattern.' }
55    if (pattern.length > MAX_PATTERN_LENGTH || isRiskyPattern(pattern)) {
56      return { ok: false, message: 'wake-me: that pattern is too long or too complex. Use a short, simple one.' }
57    }
58    try {
59      new RegExp(pattern)
60    } catch {
61      return { ok: false, message: 'wake-me: pattern is not a valid regular expression.' }
62    }
63  }
64
65  const label = typeof request.label === 'string' && request.label.trim() !== '' ? request.label.trim() : basename(path)
66  const timeout = Math.min(positiveNumber(request.timeoutMinutes) ?? DEFAULT_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES)
67  const totalSeconds = positiveNumber(request.totalSeconds)
68
69  return {
70    ok: true,
71    spec: {
72      kind,
73      path,
74      label,
75      stableSeconds: Math.min(positiveNumber(request.stableSeconds) ?? DEFAULT_STABLE_SECONDS, 3600),
76      ...(totalSeconds === undefined ? {} : { totalSeconds }),
77      ...(kind === 'log-match' && pattern !== undefined ? { pattern } : {}),
78      timeoutMinutes: timeout,
79    },
80  }
81}
82
83const armKey = (path: string): string => path.replace(/\\/g, '/').toLowerCase()
84
85export type Admission = { ok: true; armed: Record<string, number[]> } | { ok: false; message: string }
86
87/** Applies the loop guards: at most 8 live watches and 6 arms of one path per hour. */
88export function admit(watches: readonly Watch[], path: string, armed: Record<string, number[]>, now: number): Admission {
89  if (watches.filter(watch => watch.status === 'watching').length >= MAX_LIVE) {
90    return { ok: false, message: `wake-me: ${MAX_LIVE} watches are already running. Wait for one to finish, or cancel one in /waits.` }
91  }
92
93  const key = armKey(path)
94  const recent = (armed[key] ?? []).filter(at => now - at < HOUR)
95  if (recent.length >= MAX_REARMS_PER_HOUR) {
96    return {
97      ok: false,
98      message: `wake-me: ${path} was armed ${MAX_REARMS_PER_HOUR} times in the last hour. Check the job yourself instead of arming it again.`,
99    }
100  }
101
102  return { ok: true, armed: { ...armed, [key]: [...recent, now] } }
103}
104
105export const isLive = (watch: Watch): boolean => watch.status === 'watching'
106
107export function newWatch(id: number, spec: Spec, now: number, isOwn: boolean): Watch {
108  return {
109    id,
110    kind: spec.kind,
111    path: spec.path,
112    label: spec.label,
113    createdAt: now,
114    deadline: now + spec.timeoutMinutes * 60_000,
115    stableSeconds: spec.stableSeconds,
116    ...(spec.totalSeconds === undefined ? {} : { totalSeconds: spec.totalSeconds }),
117    ...(spec.pattern === undefined ? {} : { pattern: spec.pattern }),
118    isOwn,
119    status: 'watching',
120  }
121}
122
123/** What the tick saw of the watched file. */
124export type Observation = {
125  exists: boolean
126  size?: number
127  mtimeMs?: number
128  /** The file's text, read only for `progress` and `log-match`. */
129  text?: string
130}
131
132export const needsText = (watch: Watch): boolean => watch.kind === 'progress' || watch.kind === 'log-match'
133
134export type Wake = { reason: 'ready' | 'timeout'; detail: string }
135
136export type Step = { watch: Watch; wake?: Wake }
137
138const finish = (watch: Watch, wake: Wake): Step => ({
139  watch: { ...watch, status: wake.reason === 'ready' ? 'done' : 'timeout' },
140  wake,
141})
142
143/** The last line of `text` that matches `pattern`, if any, from the final 64 KiB. */
144export function matchTail(text: string, pattern: string): string | undefined {
145  const regex = new RegExp(pattern)
146  return text
147    .slice(-LOG_TAIL_CHARS)
148    .split(/\r?\n/)
149    .findLast(line => regex.test(line))
150}
151
152/** Advances a watch by one observation: its new state, and why Claude should wake, if it should. */
153export function step(watch: Watch, seen: Observation, now: number): Step {
154  const moved = seen.exists && (seen.size !== watch.size || seen.mtimeMs !== watch.mtimeMs)
155  const base: Watch = {
156    ...watch,
157    ...(seen.exists ? { size: seen.size, mtimeMs: seen.mtimeMs } : {}),
158    changedAt: moved || watch.changedAt === undefined ? now : watch.changedAt,
159  }
160
161  switch (watch.kind) {
162    case 'file-exists':
163      return seen.exists ? finish(base, { reason: 'ready', detail: 'the file exists' }) : timeoutOr(base, now, 'the file has not appeared')
164    case 'file-stable': {
165      const isQuiet = seen.exists && (seen.size ?? 0) > 0 && now - (base.changedAt ?? now) >= watch.stableSeconds * 1000
166      return isQuiet ? finish(base, { reason: 'ready', detail: `unchanged for ${watch.stableSeconds}s` }) : timeoutOr(base, now, 'the file is still changing or missing')
167    }
168    case 'progress': {
169      const reading = seen.text === undefined ? undefined : readProgress(seen.text, watch.totalSeconds)
170      const percent = reading?.percent ?? base.percent
171      const eta = estimateSeconds(percent, (now - watch.createdAt) / 1000)
172      const withProgress: Watch = {
173        ...base,
174        ...(percent === undefined ? {} : { percent }),
175        ...(eta === undefined ? {} : { etaSeconds: eta }),
176      }
177      return reading?.isDone
178        ? finish(withProgress, { reason: 'ready', detail: 'the job reports it is finished' })
179        : timeoutOr(withProgress, now, percent === undefined ? 'no progress read' : `last seen at ${percent}%`)
180    }
181    case 'log-match': {
182      const line = seen.text === undefined || watch.pattern === undefined ? undefined : matchTail(seen.text, watch.pattern)
183      return line === undefined ? timeoutOr(base, now, 'the pattern has not appeared') : finish(base, { reason: 'ready', detail: `matched: ${line.trim().slice(0, 120)}` })
184    }
185    default: {
186      const unreachable: never = watch.kind
187      return unreachable
188    }
189  }
190}
191
192function timeoutOr(watch: Watch, now: number, lastSeen: string): Step {
193  return now >= watch.deadline ? finish(watch, { reason: 'timeout', detail: lastSeen }) : { watch }
194}
195
hooks/progress.ts 71 lines
1// Reads what a long job reports about itself: ffmpeg's `-progress` blocks, or
2// a JSON sidecar of the form { "done": 40, "total": 100, "label": "frames" }.
3
4export type Reading = {
5  /** 0 to 100, when the total is known. */
6  percent?: number
7  /** The job says it is finished. */
8  isDone: boolean
9}
10
11const MICROSECONDS = 1_000_000
12
13/** The last complete `key=value` block of an ffmpeg `-progress` file; `progress=` closes a block. */
14function lastBlock(text: string): Map<string, string> {
15  let complete = new Map<string, string>()
16  let current = new Map<string, string>()
17
18  for (const line of text.split(/\r?\n/)) {
19    const match = /^([A-Za-z0-9_]+)=(.*)$/.exec(line.trim())
20    if (!match) continue
21
22    current.set(match[1] as string, match[2] as string)
23    if (match[1] === 'progress') {
24      complete = current
25      current = new Map()
26    }
27  }
28
29  return complete
30}
31
32function readFfmpeg(text: string, totalSeconds?: number): Reading | undefined {
33  const block = lastBlock(text)
34  const state = block.get('progress')
35  if (state === undefined) return undefined
36
37  const isDone = state === 'end'
38  const outUs = Number(block.get('out_time_us') ?? block.get('out_time_ms'))
39  if (isDone) return { percent: 100, isDone }
40  if (!totalSeconds || !Number.isFinite(outUs) || outUs < 0) return { isDone }
41
42  return { percent: Math.min(99, Math.floor((outUs / MICROSECONDS / totalSeconds) * 100)), isDone }
43}
44
45function readSidecar(text: string): Reading | undefined {
46  let value: unknown
47  try {
48    value = JSON.parse(text)
49  } catch {
50    return undefined
51  }
52  if (typeof value !== 'object' || value === null) return undefined
53
54  const { done, total } = value as { done?: unknown; total?: unknown }
55  if (typeof done !== 'number' || typeof total !== 'number' || total <= 0) return undefined
56
57  const isDone = done >= total
58  return { percent: isDone ? 100 : Math.min(99, Math.floor((done / total) * 100)), isDone }
59}
60
61/** The progress a file reports, or undefined when it is neither format. */
62export function readProgress(text: string, totalSeconds?: number): Reading | undefined {
63  return text.trimStart().startsWith('{') ? readSidecar(text) : readFfmpeg(text, totalSeconds)
64}
65
66/** Seconds left, from how long it took to reach `percent`; undefined too early to say. */
67export function estimateSeconds(percent: number | undefined, elapsedSeconds: number): number | undefined {
68  if (percent === undefined || percent < 2 || percent >= 100 || elapsedSeconds <= 0) return undefined
69  return Math.round((elapsedSeconds * (100 - percent)) / percent)
70}
71
types/index.d.ts 43 lines
1export type WatchKind = 'file-exists' | 'file-stable' | 'progress' | 'log-match'
2
3export type WatchStatus = 'watching' | 'done' | 'timeout' | 'cancelled'
4
5export type Watch = {
6  id: number
7  kind: WatchKind
8  /** The watched file, as a path the file API takes. */
9  path: string
10  /** What the band calls it. */
11  label: string
12  createdAt: number
13  /** Epoch ms after which the watch ends with a timeout wake. */
14  deadline: number
15  /** `file-stable`: seconds the file must stay unchanged. */
16  stableSeconds: number
17  /** `progress`: the length of the output in seconds, for percent and ETA. */
18  totalSeconds?: number
19  /** `log-match`: the regular expression. */
20  pattern?: string
21  /** True for a watch the person added in the pane: it toasts and never wakes Claude. */
22  isOwn: boolean
23  status: WatchStatus
24  /** The last size and modification time seen, and when either last changed. */
25  size?: number
26  mtimeMs?: number
27  changedAt?: number
28  /** Last progress, 0 to 100, and the estimate of seconds left. */
29  percent?: number
30  etaSeconds?: number
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'wake-me': {
36      watches: Watch[]
37      nextId: number
38      /** When each path was armed, to cap how often one path is re-armed. */
39      armed: Record<string, number[]>
40    }
41  }
42}
43