Example: status line with the last turn duration, and a toast when a turn is slow

last turn 12.3s.agentId is set (a subagent) is ignored.Constants exported from hooks/logic.ts:
| Constant | Value | Meaning | |
|---|---|---|---|
SLOW_TURN_MS | 60000 | A turn of 60 seconds or more is slow and earns a toast. | |
ENABLED_KEY | 'turn-timer.enabled' | The $.store key holding the switch. | |
USAGE | `'usage: /turn-timer [on\ | off]'` | The answer to an unknown argument. |
Durations under a minute are floored to tenths of a second (59.95 seconds reads 59.9s), so the status never shows 60.0s while no toast fires. A new session forgets the previous turn, so its first turn never toasts.
| Command | What it does | |
|---|---|---|
/turn-timer | Toggles the mod. | |
/turn-timer on | Turns it on and sets the status line to on. | |
/turn-timer off | Turns it off and sets the status line to off. | |
/turn-timer <anything else> | Answers `usage: /turn-timer [on\ | off]` and changes nothing. |
Each change shows a toast, turn-timer on or turn-timer off. The state is the boolean turn-timer.enabled in $.store; a missing key means on.
$.store. When off, the turn hook returns next(e) untouched.hooks/logic.ts, thin hooks/register.ts.agentId check).claude plugin test runs tests/logic.test.ts (the pure functions) and tests/register.test.ts (the hooks, with the store, the status line and the toast stubbed by the test kit). Nothing runs for real.
tests/register.test.ts: the chapters do not say whether the test kit accepts an agentId field on $.turn.complete. The subagent test passes it anyway.Delete this folder, or take it out of CLAUDE_CODE_PLUGIN_DIRS. To forget the switch, run $.store.delete for turn-timer.enabled from a mod, or leave it: it is one boolean.
Written for this plugin; MIT, see LICENSE in this folder (a copy of the repository LICENSE). The ../LICENSE one level up covers the BeLazy167 examples, not this one.
hooks/register.ts 60 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { ENABLED_KEY, USAGE, parseSwitch, shouldToast, slowToastText, statusText } from './logic.js'
4import type { Snapshot } from './logic.js'
5
6// The previous turn, to tell a new slow turn from the one already shown. Kept in memory on
7// purpose: after a restart the first turn is a fresh "first snapshot" and toasts nothing.
8let last: Snapshot | null = null
9
10// A missing key means on; only an explicit false turns the mod off.
11// $.store.get(key) resolves to the value or undefined (mods-doc-mods-interfaces chapter.md:744).
12async function isEnabled($: EngineInterface): Promise<boolean> {
13 return (await $.store.get(ENABLED_KEY)) !== false
14}
15
16export const register: Register = (on) => {
17 on('session.start', async ($, e, next) => {
18 // A new session must never compare its first turn with the last turn of the previous one.
19 last = null
20 // Register commands last: a throw here skips the rest of the hook.
21 // $.command.register and its fields name, description, argumentHint: mods-doc-mods-api chapter.md:34.
22 await $.command.register({ name: 'turn-timer', description: 'Turn the turn timer on or off', argumentHint: '[on|off]' })
23 // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; session.start row at :136).
24 return next(e)
25 })
26
27 // The command answers even when the mod is off, or it could never be turned back on.
28 // e.args is the text typed after the command name, or an empty string (mods-doc-mods-api chapter.md:40).
29 on('command.run', { command: 'turn-timer' }, async ($, e) => {
30 const choice = parseSwitch(e.args, await isEnabled($))
31 // { text } is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
32 if (choice === null) return { text: USAGE }
33 // $.store.set(key, value) takes any JSON value (mods-doc-mods-interfaces chapter.md:744).
34 await $.store.set(ENABLED_KEY, choice)
35 if (!choice) last = null
36 // The status line shows the switch until the next turn replaces it. $.ui.status(text) is one line
37 // under the prompt that stays until changed (mods-doc-mods-api chapter.md:221); $.ui.toast(text) shows a
38 // notification (mods-doc-mods-api chapter.md:222).
39 await $.ui.status(choice ? 'on' : 'off')
40 await $.ui.toast('turn-timer ' + (choice ? 'on' : 'off'))
41 // {} is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
42 return {}
43 })
44
45 on('turn.complete', async ($, e, next) => {
46 // Main loop only: a subagent's turn fires turn.complete with agentId set (mods-doc-mods-and-events
47 // chapter.md:253, mods-doc-mods-reference chapter.md:85).
48 if (e.agentId !== undefined || !(await isEnabled($))) return next(e)
49 // e.durationMs and e.isAborted: mods-doc-mods-and-events chapter.md:253; e.turnId identifies the turn
50 // (mods-doc-mods-and-events chapter.md:251; the turn.complete payload also lists turnId at mods-doc-mods-testing chapter.md:193).
51 const snap: Snapshot = { turnId: e.turnId, durationMs: e.durationMs, isAborted: e.isAborted }
52 const toast = shouldToast(last, snap)
53 last = snap
54 await $.ui.status(statusText(snap))
55 if (toast) await $.ui.toast(slowToastText(snap))
56 // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; turn.complete row at :126).
57 return next(e)
58 })
59}
60hooks/logic.ts 52 lines1// Pure decisions for turn-timer. No `$` here: every function takes values and returns values.
2
3/** A turn at or above this many milliseconds is slow and earns a toast. */
4export const SLOW_TURN_MS = 60_000
5
6/** The `$.store` key holding the on/off switch. A missing key means on. */
7export const ENABLED_KEY = 'turn-timer.enabled'
8
9export const USAGE = 'usage: /turn-timer [on|off]'
10
11export type Snapshot = { turnId: string; durationMs: number; isAborted: boolean }
12
13/** 800 -> "0.8s", 12345 -> "12.3s", 59950 -> "59.9s", 65000 -> "1m 05s". */
14export function formatDuration(ms: number): string {
15 // Floor to tenths, never round: 59_950 must read 59.9s, because rounding would show 60.0s
16 // while shouldToast (which compares raw milliseconds) stays silent below the threshold.
17 if (ms < 60_000) return (Math.floor(ms / 100) / 10).toFixed(1) + 's'
18 const total = Math.round(ms / 1000)
19 const seconds = String(total % 60).padStart(2, '0')
20 return Math.floor(total / 60) + 'm ' + seconds + 's'
21}
22
23export function statusText(snap: Snapshot): string {
24 const base = 'last turn ' + formatDuration(snap.durationMs)
25 return snap.isAborted ? base + ' (interrupted)' : base
26}
27
28/**
29 * Toast on the transition only: a new, slow, uninterrupted turn, with an earlier snapshot to
30 * compare against. The first snapshot (prev null) is recorded silently, and the same turnId
31 * never toasts twice.
32 */
33export function shouldToast(prev: Snapshot | null, next: Snapshot): boolean {
34 if (prev === null) return false
35 if (prev.turnId === next.turnId) return false
36 if (next.isAborted) return false
37 return next.durationMs >= SLOW_TURN_MS
38}
39
40export function slowToastText(snap: Snapshot): string {
41 return 'Slow turn: ' + formatDuration(snap.durationMs) + ' (limit ' + formatDuration(SLOW_TURN_MS) + ')'
42}
43
44/** The new switch value for `args`, or null when the argument is unknown (answer with USAGE). */
45export function parseSwitch(args: string, current: boolean): boolean | null {
46 const word = args.trim().toLowerCase()
47 if (word === '') return !current
48 if (word === 'on') return true
49 if (word === 'off') return false
50 return null
51}
52