A live band above the prompt showing running subagents and background Bash, at zero token cost.

A live band above the prompt that shows running subagents and background Bash commands. It costs zero tokens: it only observes events and draws a line.
⏳ 2 running · builder 1m (active 5s ago) · npm test 3msentinel: all done (N tasks, 6m))/plugin marketplace add danielmalka/malka-claude-mods
/plugin install sentinel@malka-claude-mods
/plugin uninstall sentinel@malka-claude-mods
Type declarations in .claude-plugin/types/ are generated by Claude Code when the mod loads (not committed). Then claude plugin test plugins/sentinel and tsc -p plugins/sentinel.
Uma faixa ao vivo acima do prompt que mostra subagentes e comandos Bash em segundo plano em execução. Custo zero de tokens: só observa eventos e desenha uma linha.
Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install sentinel@malka-claude-mods. Desinstalar: /plugin uninstall sentinel@malka-claude-mods.
hooks/register.tsx 141 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const STALL_MS = 5 * 60_000 // no activity for this long = stalled
4const TICK_MS = 15_000
5const LINGER_MS = 60_000 // how long a finished item stays in the band
6
7// ponytail: no $.state; a hot reload clears the list (timers drop too).
8type Item = {
9 label: string
10 isAgent: boolean // only a subagent has observable activity (turn.step / tool.call)
11 start: number
12 last: number
13 doneAt?: number
14 warned?: boolean
15}
16
17const mins = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`)
18
19const items = new Map<string, Item>()
20let timer: { cancel: () => void } | undefined
21let batchStart = 0
22let batchCount = 0
23
24const running = () => [...items.values()].filter(i => i.doneAt === undefined)
25
26async function add($: EngineInterface, id: string, label: string, isAgent: boolean) {
27 const now = await $.clock.now()
28 if (running().length === 0) {
29 batchStart = now
30 batchCount = 0
31 }
32 batchCount += 1
33 items.set(id, { label, isAgent, start: now, last: now })
34 timer ??= $.clock.every(TICK_MS, async () => {
35 const t = await $.clock.now()
36 for (const i of running()) {
37 if (i.isAgent && !i.warned && t - i.last > STALL_MS) {
38 i.warned = true
39 $.ui.toast(`sentinel: ${i.label} stalled for ${mins(t - i.last)}`)
40 }
41 }
42 if (running().length === 0) {
43 timer?.cancel()
44 timer = undefined
45 }
46 $.ui.invalidate('ui.render')
47 })
48 $.ui.invalidate('ui.render')
49}
50
51async function finish($: EngineInterface, id: string) {
52 const item = items.get(id)
53 if (!item || item.doneAt !== undefined) return
54 const now = await $.clock.now()
55 item.doneAt = now
56 if (running().length === 0) {
57 timer?.cancel()
58 timer = undefined
59 $.ui.toast(`sentinel: all done (${batchCount} tasks, ${mins(now - batchStart)})`)
60 }
61 $.clock.after(LINGER_MS + 500, () => $.ui.invalidate('ui.render'))
62 $.ui.invalidate('ui.render')
63}
64
65async function touch($: EngineInterface, id?: string) {
66 const item = id === undefined ? undefined : items.get(id)
67 if (item && item.doneAt === undefined) item.last = await $.clock.now()
68}
69
70export const register: Register = on => {
71 on('agent.spawn', async ($, e, next) => {
72 const r = await next(e)
73 if (r.agentId !== undefined && !e.isTeammate) {
74 await add($, r.agentId, e.description || e.subagentType, true)
75 }
76 return r
77 })
78
79 on('turn.step', async function* ($, e, next) {
80 await touch($, e.agentId)
81 return yield* next(e)
82 })
83
84 on('turn.complete', async ($, e, next) => {
85 if (e.agentId !== undefined) await finish($, e.agentId)
86 return next(e)
87 })
88
89 on('tool.call', async ($, e, next) => {
90 await touch($, e.agentId)
91 const ran = await next(e)
92 if (e.tool === 'Bash' && ran.deny === undefined) {
93 const id = (ran.result as { backgroundTaskId?: string }).backgroundTaskId
94 if (id) await add($, id, e.command.slice(0, 30), false)
95 }
96 return ran
97 })
98
99 // End of background Bash: the task notification comes back as a prompt (task-notification).
100 on('prompt.submit', async ($, e, next) => {
101 if (e.origin.kind === 'task-notification') {
102 const id = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(e.text)?.[1]
103 if (id) await finish($, id)
104 }
105 return next(e)
106 })
107
108 // Same signal, via the notification line in the transcript (structured props.task).
109 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => {
110 const id = (e.props as { task?: { id?: string } }).task?.id
111 if (id) await finish($, id)
112 return next(e)
113 })
114
115 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116 const now = await $.clock.now()
117 const live = running()
118 const recent = [...items.values()].filter(i => i.doneAt !== undefined && now - i.doneAt < LINGER_MS)
119 if (e.props.hasSurvey || live.length + recent.length === 0) return next(e)
120
121 const parts = live.map(i => {
122 const quiet = now - i.last
123 if (!i.isAgent) return `${i.label} ${mins(now - i.start)}`
124 if (quiet > STALL_MS) return `${i.label} ${mins(now - i.start)} ⚠ stalled for ${mins(quiet)}`
125 return `${i.label} ${mins(now - i.start)} (active ${mins(quiet)} ago)`
126 })
127 const head = live.length > 0 ? `⏳ ${live.length} running` : '✓ done'
128 const done = recent.map(i => `✓ ${i.label} ${mins(i.doneAt! - i.start)}`)
129 const line = [head, ...parts, ...done].join(' · ').slice(0, e.props.bodyColumns)
130
131 const under = await next(e)
132 const { Box, Text } = $.ui.resolve(e)
133 return (
134 <Box flexDirection="column">
135 {under}
136 <Text wrap="truncate-end">{line}</Text>
137 </Box>
138 )
139 })
140}
141