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

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.
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:
claude plugin install wake-me@ground-rules
Needs Claude Code 2.1.287 or later. See the repository README for updating and uninstalling.
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.
| Kind | Finishes when |
|---|---|
file-exists | The file exists. |
file-stable | Size and modification time have not changed for stableSeconds (default 15) and the file is not empty. |
progress | An 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-match | A 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.
asUser, so Claude does not read a wake as your words./waits ends with a toast and never starts a turn.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.
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.
| Hooks | session.start, tool.call for its own tool only, command.run, and ui.render for the band and the pane. |
| Reads | The watched files: existence, size, modification time, and text for progress and log-match. |
| Runs | Nothing. It uses the file API, so it works on the desktop app too. |
| Stores | The watches of this session, in Claude Code's session state. Nothing persists. |
| Never | A shell, the network, asUser. |
ToolSearch by default, so Claude may spend a round trip finding it.hooks/register.tsx 208 lines1import { 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}
208hooks/format.ts 62 lines1import 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.`
62hooks/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/watch.ts 195 lines1import 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}
195hooks/progress.ts 71 lines1// 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}
71types/index.d.ts 43 lines1export 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