SLOPSHOPPER

sentinel

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

newbandrowsguardtoastprompt
A shopper browsing a rack in a slop shop
README

sentinel

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.

  • Running items with elapsed time, e.g. ⏳ 2 running · builder 1m (active 5s ago) · npm test 3m
  • Stall warning toast when a subagent shows no activity for 5 minutes
  • Completion toast when everything has finished (sentinel: all done (N tasks, 6m))
  • Finished items linger in the band for one minute

Install

/plugin marketplace add danielmalka/malka-claude-mods
/plugin install sentinel@malka-claude-mods

Uninstall

/plugin uninstall sentinel@malka-claude-mods

Development

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.


Português

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.

  • Itens em execução com tempo decorrido
  • Aviso (toast) quando um subagente fica 5 minutos sem atividade
  • Toast ao terminar tudo
  • Itens concluídos permanecem na faixa por um minuto

Instalar: /plugin marketplace add danielmalka/malka-claude-mods e depois /plugin install sentinel@malka-claude-mods. Desinstalar: /plugin uninstall sentinel@malka-claude-mods.

Source 1 files
hooks/register.tsx 141 lines
1import 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