A Claude Code panel that shows when your subagents will finish, queues messages for after Claude is done, offers a one-click fresh session, and keeps your…

Clawd Watch keeps an eye on Claude Code while it works: see when your subagents will finish, queue messages for after Claude is done, start the next piece of work in a fresh session with one click, and keep your favourite skills one click away.
A community project: not made by or affiliated with Anthropic.

| Section | What it shows |
|---|---|
| Subagents | When your subagents will finish, and what each is doing |
| Next session | A one-click handoff to a fresh session |
| Queued | Messages that wait until Claude finishes, instead of steering the current task |
| Favourite skills | Your pinned and most-used skills, and every skill, searchable |
In the desktop app Clawd Watch is a panel in the sidebar, beside the conversation, made of cards like the app's own. Click a card's title to collapse it, and again to open it; cards stay the way you left them.
/watch, and it stays closed in later sessions until you open it again./watch.In the terminal the same sections sit above the prompt, drawn in text (see Where it works). Click ▾ Clawd Watch (or press h after ctrl+x tab) to fold them to one line; it stays folded in new sessions. In the fullscreen layout, /watch docks the panel on the right instead.
Requirements: a Claude Code build with mods (plugins of function hooks). Clawd Watch was built against Claude Code 2.1.288. The mods API is in early access and may change between releases.
Paste this into Claude Code:
Install Clawd Watch for me: clone https://github.com/Vansitha/clawd-watch into ~/.claude/mods/clawd-watch and follow the INSTALL.md inside it.
Claude asks whether you want Clawd Watch in the desktop app, the terminal or both. Then it clones the repo, runs the installer (or does the same by hand on Windows), checks the plugin, and tells you what to do next. It also asks before changing any keys. The steps it follows are in INSTALL.md.
git clone https://github.com/Vansitha/clawd-watch.git ~/.claude/mods/clawd-watch
~/.claude/mods/clawd-watch/install.sh
The installer asks where Clawd Watch should run: the desktop app and the terminal, the desktop app only, or the terminal only. Skip the question with --only desktop, --only terminal or --only both.
Then quit and reopen the Claude desktop app, or start a new claude session. Type / in the prompt: /watch, /dock, /queue and /handoff should be in the list.
The installer adds the clone to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json and keeps everything else there. It saves the old file as settings.json.bak, then checks the plugin. Run it again any time to update.
| To | Run |
|---|---|
| Update | ~/.claude/mods/clawd-watch/install.sh (it pulls the latest version first and keeps your choices) |
| Change where it runs | ~/.claude/mods/clawd-watch/install.sh --only desktop (or terminal, both), or Show in under /config |
| Uninstall | ~/.claude/mods/clawd-watch/install.sh --uninstall |
| Try it in one terminal session without changing your settings | claude --plugin-dir ~/.claude/mods/clawd-watch |
On Windows, follow INSTALL.md step 3b, or ask Claude to.
Used the three separate mods (session-hud, message-queue and skill-dock), or installed it when it was called claude-mods or Overwatch? Run the installer as above. It replaces the old mods in your settings and keeps your Show in choice, and on its first start Clawd Watch takes over your pins, skill history, hidden skills and subagent timings. If an old clone sits in a folder of its own (~/.claude/mods/claude-mods or ~/.claude/mods/overwatch), delete it.
Subagents All done by ~5:59 PM
● Build rooms: crew and seats done by ~5:57 PM
━━━━━━━────────── Reading crew-tokens.ts
● Remove the hands overlay done by ~5:59 PM
━━━────────────── Editing Hand.tsx
Next session Build the upgrades screen [Start fresh session] [Copy] [Dismiss]
Subagents. While subagents run, the header says when they'll all be done. Each agent shows when it will be done, a progress bar, and what it's doing right now.
Claude Code doesn't report real progress for subagents, so these are estimates. When a subagent starts, a small, fast model (Haiku) reads its task and guesses how many minutes and tool calls it needs; the row says estimating… for the second or two that takes. Each finished run is compared with its guess, and later guesses are scaled to match how long your subagents really take. Clawd Watch remembers this across sessions, so estimates improve the more you use it. If the guess can't be made, the estimate falls back to how long past subagents of the same type took. Sizing costs one short Haiku call per subagent.
Handoff to a fresh session. Start fresh session clears the conversation and sends a starter prompt for the next piece of work, so you don't have to write one. The prompt is also copied to your clipboard.
/handoff writes the starter prompt on demand. Add a focus if you like: /handoff the payments refactor./handoff auto off and /handoff auto on turn the automatic suggestions off or on.Queued (2) 1 Then add tests for the rooms screen and run them ✕
2 Update the README with the new crew and seats flow ↑ ✕
Sent one at a time, each after Claude finishes.
Queue a message for Claude to read after it finishes what it's doing, instead of steering the task it's on.
chat:queueSubmit, ctrl+x enter by default./queue <message> does the same from the prompt, and works everywhere, the desktop app included. /queue clear empties the queue.Add this to ~/.claude/keybindings.json, merging it with anything already there:
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"$docs": "https://code.claude.com/docs/en/keybindings",
"bindings": [
{ "context": "Chat", "bindings": { "cmd+enter": "chat:queueSubmit" } }
]
}
Cmd+Enter then queues, and Shift+Enter still starts a new line. The terminal only sees Cmd if it reports it (kitty and Ghostty do; Terminal.app and many others don't). Where it doesn't, ctrl+x enter and /queue <message> always work. Newer desktop apps already queue with ⌘⏎ (the prompt says ⌘⏎ to queue for later).
Favourite skills ★ code-review ★ pdf ★ deep-research │ simplify xlsx All skills (12) ›
Your pinned skills and your most-used ones, one click away.
/skill-name at the front of the prompt box, keeping anything you'd already typed. Add details and press Enter./name or Claude loaded it./dock) turns the panel to every skill, in three lists (‹ Back returns):Click ☆ to pin a skill, or click a name to use it. Where the app can't show a side panel, the lists open above the prompt instead.
~/.claude/skills or the project's .claude/skillsnpx skills add; the tag says which one (for example pbakaus/impeccable)~/.claude/clawd-watch/removed-skills, and moving it back restores it. Run /reload-skills to drop it from the / menu straight away./dock pin <skill>, /dock unpin <skill>, /dock hide <skill> and /dock unhide <skill> do the same from the prompt./ menu, pinned skills say ★ pinned and used ones say how often you used them in the last 30 days.Built-in skills such as simplify look like ordinary commands until you use them once. After that they show up in the lists.

In the terminal, the same panel is drawn in text:
s: Start fresh session, and skills are a plain list rather than boxed chips.ctrl+x tab to move into the panel (the panel shows this hint), then use Tab and Enter, or the letter before a button. Esc returns to the prompt./watch docks it on the right.Clawd Watch has a Show in setting (both, desktop or terminal), under /config or set by the installer. Switched off for a surface, it draws nothing there and stays out of the way. It still counts the skills you use there, so your lists stay right.
claude): everything, drawn in text as above.The repo is the plugin: a manifest (.claude-plugin/plugin.json), a state contract (types/index.d.ts), tests (tests/) and the hooks module (hooks/):
| File | What it holds |
|---|---|
hooks/register.tsx | The entry: the panel above the prompt and in the sidebar, /watch, and taking over the old mods' saved data |
hooks/subagents.tsx | Subagents, their estimates, and the handoff to a fresh session |
hooks/queue.tsx | The queue |
hooks/skills.tsx | Favourite skills and the skills list |
hooks/pane.tsx, hooks/shared.tsx | The sidebar pane, and what every section shares (cards, layout, Show in) |
claude plugin validate . && claude plugin test .
tests/install.test.sh checks the installer: it runs both of its settings editors (Node and Python) on the same awkward settings.json files and checks they agree.
bash tests/install.test.sh
Load it with claude --plugin-dir . while you work on it: it reloads whenever you save.
Issues and pull requests are welcome.
hooks/register.tsx 340 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import {
5 PANE,
6 closePane,
7 isPaneUnavailable,
8 openPane,
9 paneClosed,
10 paneState,
11 setPaneView,
12 syncPane,
13 wantsPane,
14} from './pane.js'
15import {
16 queueSections,
17 queueSummary,
18 queueTurnComplete,
19 registerQueue,
20 startQueue,
21} from './queue.js'
22import {
23 HINT_TEXT,
24 NARROW_COLUMNS,
25 SECTION_GAP,
26 collapsedList,
27 isActive,
28 loadCards,
29 runsOn,
30} from './shared.js'
31import type { View } from './shared.js'
32import { allSkills, registerSkills, skillsSections, startSkills } from './skills.js'
33import {
34 registerSubagents,
35 startSubagents,
36 subagentSections,
37 subagentSummary,
38 subagentsTurnComplete,
39} from './subagents.js'
40
41const PANEL_NAME = 'Clawd Watch'
42const PANEL_KEY = 'clawd-watch-panel'
43const STORE_FOLDED = 'folded'
44/** True while the band above the prompt is folded to one line. */
45const isFolded = atom({ plugin: 'clawd-watch', key: 'isFolded' } as const, false)
46
47/** The mods Clawd Watch was split into before, whose saved data it takes over once. */
48const OLD_MODS = ['session-hud', 'message-queue', 'skill-dock']
49const OLD_STORE_DIR = '.claude/plugins/store'
50const STORE_MIGRATED = 'migratedFromMods'
51const MIGRATED_KEYS = [
52 'pins',
53 'uses',
54 'knownSkills',
55 'hidden',
56 'agentHistory',
57 'autoSuggest',
58 'collapsedCards',
59]
60
61/** Whether another plugin drew anything above Clawd Watch. */
62function hasContent(tree: unknown): boolean {
63 return ((tree as { children?: unknown[] }).children?.length ?? 0) > 0
64}
65
66/**
67 * Takes over what the old session-hud, message-queue and skill-dock mods saved (pins, skill use,
68 * subagent timings), once, keeping anything Clawd Watch already has.
69 */
70async function migrate($: EngineInterface) {
71 if ((await $.store.get(STORE_MIGRATED)) === true) return
72 const home = await $.env.get('HOME').catch(() => undefined)
73 const dir = home && `${home}/${OLD_STORE_DIR}`
74 const files = dir ? await $.fs.list(dir).catch(() => []) : []
75
76 for (const file of files) {
77 const isOld = OLD_MODS.some(mod => file.name.startsWith(`${mod}_`))
78 if (!isOld || !file.name.endsWith('.json')) continue
79 const saved = await $.fs
80 .read(`${dir}/${file.name}`)
81 .then(text => JSON.parse(text) as Record<string, unknown>)
82 .catch(() => undefined)
83 for (const key of MIGRATED_KEYS) {
84 if (saved?.[key] !== undefined && (await $.store.get(key)) === undefined) {
85 await $.store.set(key, saved[key])
86 }
87 }
88 }
89 await $.store.set(STORE_MIGRATED, true)
90}
91
92async function loadFolded($: EngineInterface) {
93 const stored = await $.store.get(STORE_FOLDED)
94 if (typeof stored === 'boolean') await update($, isFolded, () => stored)
95}
96
97async function setFolded($: EngineInterface, folded: boolean) {
98 await Promise.all([update($, isFolded, () => folded), $.store.set(STORE_FOLDED, folded)])
99}
100
101/** One line on what's running, queued or ready, for the folded band and the closed pane. */
102async function summary($: EngineInterface): Promise<string> {
103 const parts = await Promise.all([subagentSummary($), queueSummary($)])
104
105 return parts.flat().join(' · ')
106}
107
108/** The panel's top row: its name, which opens, folds or unfolds it, then what's going on. */
109function panelBar(
110 t: ElementTable,
111 bar: { label: string; summary: string; action?: string; hint?: string; onPress: () => unknown },
112) {
113 const { Box, Text, Button } = t
114
115 return (
116 <Box key={PANEL_KEY} flexDirection="row" gap={2} alignItems="center">
117 <Button
118 key="clawd-watch-fold"
119 plain
120 dimColor
121 hotkey="h"
122 label={bar.label}
123 onPress={() => void bar.onPress()}
124 />
125 <Box flexGrow={1} flexShrink={1} minWidth={0}>
126 {bar.summary !== '' && (
127 <Text dimColor wrap="truncate-end">
128 {bar.summary}
129 </Text>
130 )}
131 </Box>
132 {bar.action && (
133 <Button
134 key="clawd-watch-open"
135 plain
136 dimColor
137 label={bar.action}
138 onPress={() => void bar.onPress()}
139 />
140 )}
141 {bar.hint && (
142 <Text dimColor italic>
143 {bar.hint}
144 </Text>
145 )}
146 </Box>
147 )
148}
149
150/** Every section with something to show, in the panel's order. */
151async function allSections($: EngineInterface, t: ElementTable, view: View) {
152 const groups = await Promise.all([
153 subagentSections($, t, view),
154 queueSections($, t, view),
155 skillsSections($, t, view),
156 ])
157
158 return groups.flat()
159}
160
161export const register: Register = (on, options) => {
162 const showIn = String(options.show_in ?? 'both')
163
164 on('session.start', async ($, e, next) => {
165 // Before anything loads what it saved, take over what the old mods saved.
166 await migrate($).catch(() => undefined)
167 const result = await next(e)
168 await Promise.all([
169 loadFolded($).catch(() => undefined),
170 loadCards($).catch(() => undefined),
171 $.command
172 .register({ name: 'watch', description: 'Open or close the Clawd Watch panel' })
173 .catch(() => undefined),
174 startSubagents($),
175 startQueue($),
176 startSkills($, e.cwd),
177 ])
178
179 return result
180 })
181
182 on('turn.complete', async ($, e, next) => {
183 const result = await next(e)
184 await Promise.all([subagentsTurnComplete($, e, showIn), queueTurnComplete($, e, showIn)])
185
186 return result
187 })
188
189 registerSubagents(on, showIn)
190 registerQueue(on, showIn)
191 registerSkills(on, showIn)
192
193 // The desktop app shows the panel in its sidebar, unless the person closed it there.
194 on('session.attach', async ($, e, next) => {
195 const result = await next(e)
196 if (e.surface !== 'terminal' && runsOn(showIn, e.surface) && (await wantsPane($))) {
197 await openPane($, 'overview', { isAsked: false }).catch(() => undefined)
198 } else {
199 await syncPane($).catch(() => undefined)
200 }
201
202 return result
203 })
204
205 on('ui.close', async ($, e, next) => {
206 const result = await next(e)
207 if (e.id === PANE) await paneClosed($, e.origin.kind !== 'unload')
208
209 return result
210 })
211
212 on('command.run', { command: 'watch' }, async ($, e) => {
213 if (!(await isActive($, showIn))) {
214 return { text: `Clawd Watch is set to show in the ${showIn} only. Change it under /config.` }
215 }
216 if ((await paneState($)).isShown) {
217 await closePane($)
218 return { text: 'Closed the Clawd Watch panel.' }
219 }
220 const isPlaced = await openPane($)
221
222 return {
223 text: isPlaced
224 ? 'Opened the Clawd Watch panel.'
225 : "Clawd Watch can't open a side panel here, so it stays above the prompt.",
226 }
227 })
228
229 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230 // Other plugins' rows come first; Clawd Watch follows, nearest the prompt.
231 const above = await next(e)
232 if (e.props.hasSurvey || !runsOn(showIn, e.surface)) return above
233
234 const t = $.ui.resolve(e)
235 const { Box } = t
236 const isText = e.surface === 'terminal'
237 const [pane, folded, unavailable, collapsed] = await Promise.all([
238 paneState($),
239 read($, isFolded),
240 isPaneUnavailable($),
241 collapsedList($),
242 ])
243 const gap = hasContent(above) ? SECTION_GAP : 0
244 const withBand = (band: JSX.Element) => (
245 <Box flexDirection="column">
246 {above}
247 <Box flexDirection="column" marginTop={gap}>
248 {band}
249 </Box>
250 </Box>
251 )
252
253 // While the pane is drawn beside the transcript, it has everything.
254 if (pane.isShown && e.viewport?.isFullscreen !== false) return above
255
256 // The desktop app keeps one line here, which opens the pane.
257 if (!isText && !unavailable) {
258 return withBand(
259 panelBar(t, {
260 label: `▸ ${PANEL_NAME}`,
261 summary: await summary($),
262 action: 'Open panel',
263 onPress: () => openPane($),
264 }),
265 )
266 }
267
268 const view: View = {
269 surface: e.surface,
270 isText,
271 isStacked: !isText || e.props.bodyColumns < NARROW_COLUMNS,
272 columns: e.props.bodyColumns,
273 maxRows: e.props.maxRows,
274 collapsed,
275 }
276 const sections = folded ? [] : await allSections($, t, view)
277 if (!folded && sections.length === 0) return above
278 // The terminal takes no clicks outside fullscreen: say how to reach the buttons.
279 const hint = isText && e.viewport?.isFullscreen !== true ? HINT_TEXT : undefined
280
281 return withBand(
282 <Box flexDirection="column" gap={SECTION_GAP}>
283 {panelBar(t, {
284 label: `${folded ? '▸' : '▾'} ${PANEL_NAME}`,
285 summary: folded ? await summary($) : '',
286 ...(hint ? { hint } : {}),
287 onPress: () => setFolded($, !folded),
288 })}
289 {sections}
290 </Box>,
291 )
292 })
293
294 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
295 if (!runsOn(showIn, e.surface)) return next(e)
296
297 const t = $.ui.resolve(e)
298 const { Box, Text, Button } = t
299 const view: View = {
300 surface: e.surface,
301 isText: e.surface === 'terminal',
302 isStacked: true,
303 columns: e.props.bodyColumns,
304 maxRows: e.props.scroll.bodyRows,
305 collapsed: await collapsedList($),
306 }
307
308 if ((await paneState($)).view === 'skills') {
309 return (
310 <Box flexDirection="column" gap={SECTION_GAP}>
311 <Box flexDirection="row">
312 <Button
313 key="pane-back"
314 plain
315 dimColor
316 label="‹ Back"
317 onPress={() => setPaneView($, 'overview')}
318 />
319 </Box>
320 {await allSkills($, t, view)}
321 </Box>
322 )
323 }
324
325 const sections = await allSections($, t, view)
326 const isQuiet = (await summary($)) === ''
327
328 return (
329 <Box flexDirection="column" gap={SECTION_GAP}>
330 {isQuiet && (
331 <Text dimColor>
332 Subagents, queued messages and handoffs show up here while Claude works.
333 </Text>
334 )}
335 {sections}
336 </Box>
337 )
338 })
339}
340hooks/pane.tsx 75 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { PaneView } from '../types'
5
6/** The sidebar pane every section draws into, where the surface docks one. */
7export const PANE = 'clawd-watch'
8const PANE_TITLE = 'Clawd Watch'
9const STORE_PANE = 'paneOpen'
10
11const paneView = atom({ plugin: 'clawd-watch', key: 'paneView' } as const, 'overview')
12/** Whether the pane is open and drawn; while it is, the band above the prompt stays out of the way. */
13const paneShown = atom({ plugin: 'clawd-watch', key: 'paneShown' } as const, false)
14/** True once the person asked for the pane and no surface could place it: the band keeps it all. */
15const paneUnavailable = atom({ plugin: 'clawd-watch', key: 'paneUnavailable' } as const, false)
16
17/** Opens the pane on a view (or moves an open one to it); says whether a surface drew it. */
18export async function openPane(
19 $: EngineInterface,
20 view: PaneView = 'overview',
21 options: { isAsked: boolean } = { isAsked: true },
22): Promise<boolean> {
23 await update($, paneView, () => view)
24 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
25 await update($, paneShown, () => opened.isPlaced)
26 // Opening it is a choice the next session keeps; an automatic open is not.
27 if (options.isAsked) {
28 await Promise.all([
29 $.store.set(STORE_PANE, true),
30 update($, paneUnavailable, () => !opened.isPlaced),
31 ])
32 }
33
34 return opened.isPlaced
35}
36
37/** Closes the pane as the person's choice; this plugin's own close may not come back to its hooks. */
38export async function closePane($: EngineInterface) {
39 await $.ui.close({ id: PANE })
40 await paneClosed($, true)
41}
42
43/** After the pane closes: a close the person or a button made is kept for the next session. */
44export async function paneClosed($: EngineInterface, isChosen: boolean) {
45 await Promise.all([update($, paneShown, () => false), update($, paneView, () => 'overview')])
46 if (isChosen) await $.store.set(STORE_PANE, false)
47}
48
49/** Whether the person keeps the pane open; it is, until they close it. */
50export async function wantsPane($: EngineInterface): Promise<boolean> {
51 return (await $.store.get(STORE_PANE)) !== false
52}
53
54/** Checks with the engine whether the pane is drawn, after a surface comes or goes. */
55export async function syncPane($: EngineInterface) {
56 const pane = (await $.ui.panes()).find(one => one.id === PANE)
57 const isPlaced = pane?.isPlaced === true
58 await update($, paneShown, () => isPlaced)
59 if (isPlaced) await update($, paneUnavailable, () => false)
60}
61
62export async function paneState($: EngineInterface) {
63 const [isShown, view] = await Promise.all([read($, paneShown), read($, paneView)])
64
65 return { isShown, view }
66}
67
68export function isPaneUnavailable($: EngineInterface): Promise<boolean> {
69 return read($, paneUnavailable)
70}
71
72export function setPaneView($: EngineInterface, view: PaneView) {
73 return update($, paneView, () => view)
74}
75hooks/queue.tsx 275 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, On, Timer, TurnCompleteInput } from 'claude-code'
3
4import type { QueuedMessage } from '../types'
5import { ACCENT, COMPACT_COLUMNS, cardFor, isActive, plural, section } from './shared.js'
6import type { View } from './shared.js'
7
8const messages = atom({ plugin: 'clawd-watch', key: 'messages' } as const, [])
9const isPaused = atom({ plugin: 'clawd-watch', key: 'isPaused' } as const, false)
10
11/** Columns for a message's number, and for its ↑ ✕ controls. */
12const INDEX_WIDTH = 2
13const CONTROLS_WIDTH = 5
14/** Lets the finished turn's last rows land before the next message starts a new turn. */
15const SEND_DELAY_MS = 300
16
17const AFTER_TASK = 'Claude will read it after the current task.'
18const QUEUED_NOTICE = `Queued. ${AFTER_TASK}`
19const WAITING_NOTE = 'Sent one at a time, each after Claude finishes.'
20const PAUSED_NOTE = 'Paused because you stopped Claude.'
21
22const local: { isWorking: boolean; isSending: boolean; nextId: number; pendingSend?: Timer } = {
23 isWorking: false,
24 isSending: false,
25 nextId: 1,
26}
27
28function firstLine(text: string): string {
29 const lines = text.trim().split('\n')
30
31 return lines.length > 1 ? `${lines[0]} …` : (lines[0] ?? '')
32}
33
34async function enqueue($: EngineInterface, text: string) {
35 const id = `${await $.clock.now()}-${local.nextId++}`
36 const list = await update($, messages, all => [...all, { id, text }])
37 $.ui.toast(`Queued (${list.length}). ${AFTER_TASK}`)
38}
39
40/** Removes messages; an empty queue is no longer paused. */
41async function removeWhere($: EngineInterface, isRemoved: (m: QueuedMessage) => boolean) {
42 const list = await update($, messages, all => all.filter(m => !isRemoved(m)))
43 if (list.length === 0) await update($, isPaused, () => false)
44}
45
46/** Sends the first queued message as the person's own words, as a turn of its own. */
47async function sendNext($: EngineInterface) {
48 if (local.isSending) return
49 const [first] = await read($, messages)
50 if (!first) return
51
52 local.isSending = true
53 try {
54 await update($, messages, all => all.filter(m => m.id !== first.id))
55 await $.prompt.submit({ text: first.text, asUser: true })
56 await update($, isPaused, () => false)
57 } catch {
58 // Not sent: put it back at the front rather than lose it.
59 await update($, messages, all => [first, ...all])
60 $.ui.toast("Couldn't send the queued message; it's still first in the queue.")
61 } finally {
62 local.isSending = false
63 }
64}
65
66async function moveUp($: EngineInterface, id: string) {
67 await update($, messages, all => {
68 const index = all.findIndex(m => m.id === id)
69 if (index < 1) return all
70
71 const reordered = [...all]
72 ;[reordered[index - 1], reordered[index]] = [all[index]!, all[index - 1]!]
73
74 return reordered
75 })
76}
77
78function messageRow(
79 $: EngineInterface,
80 t: ElementTable,
81 m: QueuedMessage,
82 index: number,
83 isText: boolean,
84) {
85 const { Box, Text, Button } = t
86 // Where there's a pointer, a row's controls show only while it's hovered.
87 const reveal = isText ? {} : { display: 'none' as const, hover: { display: 'flex' as const } }
88
89 return (
90 <Box key={`queue-row-${m.id}`} flexDirection="row" gap={1} alignItems="center">
91 <Box width={INDEX_WIDTH} flexShrink={0}>
92 <Text color={isText ? ACCENT : undefined} dimColor={!isText}>{`${index + 1}`}</Text>
93 </Box>
94 <Box flexGrow={1} flexShrink={1} minWidth={0}>
95 <Text wrap="truncate-end">{firstLine(m.text)}</Text>
96 </Box>
97 <Box width={CONTROLS_WIDTH} flexShrink={0} justifyContent="flex-end">
98 <Box flexDirection="row" gap={1} {...reveal}>
99 {index > 0 && (
100 <Button
101 key={`queue-up-${m.id}`}
102 plain
103 dimColor
104 label="↑"
105 onPress={() => moveUp($, m.id)}
106 />
107 )}
108 <Button
109 key={`queue-remove-${m.id}`}
110 plain
111 dimColor
112 label="✕"
113 onPress={() => removeWhere($, x => x.id === m.id)}
114 />
115 </Box>
116 </Box>
117 </Box>
118 )
119}
120
121/** The hooks behind the Queued section, the queue key and /queue. */
122export function registerQueue(on: On, showIn: string) {
123 on('turn.start', ($, e, next) => {
124 local.isWorking = true
125 return next(e)
126 })
127
128 // The queue key (chat:queueSubmit) during a turn holds the message instead of steering.
129 on('prompt.submit', async ($, e, next) => {
130 const isQueueKey = e.wait && e.origin.kind === 'composer' && e.turnId !== undefined
131 if (!isQueueKey || !(await isActive($, showIn))) return next(e)
132
133 if (e.attachments?.length) {
134 $.ui.toast("Messages with images can't be queued, so this one was sent now.")
135 return next(e)
136 }
137
138 await enqueue($, e.text)
139
140 return { drop: QUEUED_NOTICE }
141 })
142 // If queueing fails, the message is sent as usual rather than lost.
143 .catch(($, e, next) => next(e))
144
145 on('command.run', { command: 'queue' }, async ($, e) => {
146 if (!(await isActive($, showIn))) {
147 return {
148 text: `Clawd Watch is set to show in the ${showIn} only. Change it under /config.`,
149 }
150 }
151
152 const args = (e.args ?? '').trim()
153 const count = (await read($, messages)).length
154
155 if (args === 'clear') {
156 await removeWhere($, () => true)
157 return { text: count ? `Cleared ${plural(count, 'queued message')}.` : 'Nothing is queued.' }
158 }
159 if (args === 'send') {
160 if (local.isWorking) {
161 return { text: 'Claude is still working; the next message goes when it finishes.' }
162 }
163 await sendNext($)
164 return { text: 'Sent the next queued message.' }
165 }
166 if (!args) {
167 return {
168 text: count
169 ? `${plural(count, 'message')} queued.`
170 : 'Nothing is queued. Use /queue <message>.',
171 }
172 }
173 if (!local.isWorking && count === 0) {
174 await $.prompt.submit({ text: args, asUser: true })
175 return { text: 'Claude is idle, so it was sent now.' }
176 }
177
178 await enqueue($, args)
179
180 return { text: 'Queued.' }
181 })
182}
183
184/** The Queued section, when anything is queued. */
185export async function queueSections(
186 $: EngineInterface,
187 t: ElementTable,
188 view: View,
189): Promise<JSX.Element[]> {
190 const [list, paused] = await Promise.all([read($, messages), read($, isPaused)])
191 if (list.length === 0) return []
192
193 const { Box, Text, Button } = t
194 // The status sits beside the section's name where there's room, otherwise under the messages.
195 const isBeside = view.isStacked && view.columns >= COMPACT_COLUMNS
196 const status = (
197 <Box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
198 <Text dimColor>{paused ? PAUSED_NOTE : WAITING_NOTE}</Text>
199 {paused && (
200 <Button
201 key="queue-send"
202 plain={view.isText || undefined}
203 variant="primary"
204 hotkey="n"
205 label="Send next"
206 onPress={() => sendNext($)}
207 />
208 )}
209 </Box>
210 )
211
212 return [
213 section(
214 t,
215 `Queued (${list.length})`,
216 <Box flexDirection="column">
217 {list.map((m, index) => messageRow($, t, m, index, view.isText))}
218 {!isBeside && (
219 <Box paddingLeft={INDEX_WIDTH + 1} marginTop={view.isText ? 0 : 1}>
220 {status}
221 </Box>
222 )}
223 </Box>,
224 {
225 isStacked: view.isStacked,
226 card: cardFor($, view, 'Queued'),
227 ...(isBeside ? { aside: status } : {}),
228 },
229 ),
230 ]
231}
232
233/** What the folded panel says about the queue. */
234export async function queueSummary($: EngineInterface): Promise<string[]> {
235 const count = (await read($, messages)).length
236
237 return count > 0 ? [`${count} queued`] : []
238}
239
240/** At session start: /queue. */
241export async function startQueue($: EngineInterface) {
242 await $.command
243 .register({
244 name: 'queue',
245 description: 'Queue a message for Claude to read after the current task',
246 argumentHint: '<message> | send | clear',
247 immediate: true,
248 })
249 .catch(() => undefined)
250}
251
252/** When a turn ends: send the next queued message, or hold the queue if the person stopped Claude. */
253export async function queueTurnComplete($: EngineInterface, e: TurnCompleteInput, showIn: string) {
254 if (e.agentId !== undefined) return
255
256 local.isWorking = false
257 const [list, paused] = await Promise.all([read($, messages), read($, isPaused)])
258 if (list.length === 0 || !(await isActive($, showIn))) return
259
260 // One send per finished task: a repeated end replaces the pending send, and a turn that
261 // starts first (a message typed just then) goes ahead, its own end sending this.
262 local.pendingSend?.cancel()
263 if (e.reason === 'answer') {
264 if (!paused) {
265 local.pendingSend = $.clock.after(SEND_DELAY_MS, () => {
266 local.pendingSend = undefined
267 if (!local.isWorking) void sendNext($)
268 })
269 }
270 } else {
271 // The person stopped the turn: hold the queue until they send it.
272 await update($, isPaused, () => true)
273 }
274}
275hooks/shared.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, RenderSurface } from 'claude-code'
3
4/** The name column every section shares in the terminal, wide enough for "Favourite skills". */
5export const LABEL_WIDTH = 17
6/** Below this many columns the terminal stacks a section's name above its content. */
7export const NARROW_COLUMNS = 72
8/** The terminal's chord that hands the keyboard to the panel's buttons. */
9export const HINT_TEXT = 'ctrl+x tab'
10/** The desktop app's cards: the prompt box's own border colour, and their inner spacing. */
11const CARD_BORDER = 'promptBorder'
12const CARD_PADDING = { x: 2, y: 1 }
13/** Below this many columns (a docked pane) rows wrap onto two lines rather than squeeze. */
14export const COMPACT_COLUMNS = 70
15/** Rows between sections, and between cards. */
16export const SECTION_GAP = 1
17export const ACCENT = '#d97757'
18
19const STORE_CARDS = 'collapsedCards'
20const collapsedCards = atom({ plugin: 'clawd-watch', key: 'collapsedCards' } as const, [])
21
22/** How a section is drawn: where, how much room, and which cards are collapsed. */
23export type View = {
24 surface: RenderSurface
25 /** The terminal draws text; the desktop app draws cards. */
26 isText: boolean
27 /** The name above the content (the desktop app, a narrow terminal) rather than beside it. */
28 isStacked: boolean
29 columns: number
30 maxRows: number
31 collapsed: readonly string[]
32}
33
34/** A collapsible card's state and toggle, as the desktop app draws sections. */
35export type Card = { key: string; isCollapsed: boolean; onToggle: () => unknown }
36
37/** Whether the "Show in" setting (both, desktop or terminal) includes a surface. */
38export function runsOn(showIn: string, surface: RenderSurface): boolean {
39 if (showIn === 'desktop') return surface !== 'terminal'
40 if (showIn === 'terminal') return surface === 'terminal'
41
42 return true
43}
44
45/** Whether Clawd Watch runs in this session, judged by the surfaces drawing it right now. */
46export async function isActive($: EngineInterface, showIn: string): Promise<boolean> {
47 if (showIn === 'both') return true
48 const surfaces = await $.session.surfaces()
49
50 // Until a client attaches (the desktop app, the SDK) nothing rules it out.
51 return surfaces.length === 0 || surfaces.some(surface => runsOn(showIn, surface))
52}
53
54export function plural(count: number, word: string): string {
55 return `${count} ${word}${count === 1 ? '' : 's'}`
56}
57
58/** The names of the desktop app's cards left collapsed. */
59export function collapsedList($: EngineInterface): Promise<string[]> {
60 return read($, collapsedCards)
61}
62
63/** Collapses a card, or opens it again, and keeps that for later sessions. */
64async function toggleCard($: EngineInterface, name: string) {
65 const list = await update($, collapsedCards, all =>
66 all.includes(name) ? all.filter(one => one !== name) : [...all, name],
67 )
68 await $.store.set(STORE_CARDS, list)
69}
70
71export async function loadCards($: EngineInterface) {
72 const stored = await $.store.get(STORE_CARDS)
73 if (Array.isArray(stored)) {
74 await update($, collapsedCards, () =>
75 stored.filter((one): one is string => typeof one === 'string'),
76 )
77 }
78}
79
80/** A section's card in the desktop app, collapsed as it was left; the terminal draws no cards. */
81export function cardFor(
82 $: EngineInterface,
83 view: Pick<View, 'isText' | 'collapsed'>,
84 name: string,
85): Card | undefined {
86 if (view.isText) return undefined
87
88 return {
89 key: `card-${name.toLowerCase().replace(/\W+/g, '-')}`,
90 isCollapsed: view.collapsed.includes(name),
91 onToggle: () => toggleCard($, name),
92 }
93}
94
95/**
96 * A section. In the desktop app it is a card, as the app's own sidebar draws them: a title whose
97 * chevron collapses it, `aside` at the right, the content below. In the terminal its name sits in
98 * the shared left column (above the content when narrow), the content beside it and `aside` at
99 * the far right.
100 */
101export function section(
102 t: ElementTable,
103 label: string,
104 content: JSX.Element,
105 layout: { isStacked: boolean; aside?: JSX.Element; isCentred?: boolean; card?: Card },
106) {
107 const { Box, Text, Button } = t
108 const { isStacked, aside, isCentred = false, card } = layout
109
110 if (card) {
111 return (
112 <Box
113 flexDirection="column"
114 borderStyle="round"
115 borderColor={CARD_BORDER}
116 paddingX={CARD_PADDING.x}
117 paddingY={CARD_PADDING.y}
118 >
119 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
120 <Button
121 key={card.key}
122 plain
123 label={`${label} ${card.isCollapsed ? '›' : '⌄'}`}
124 onPress={() => void card.onToggle()}
125 />
126 {aside}
127 </Box>
128 {!card.isCollapsed && (
129 <Box flexDirection="column" marginTop={1}>
130 {content}
131 </Box>
132 )}
133 </Box>
134 )
135 }
136
137 if (isStacked) {
138 return (
139 <Box flexDirection="column">
140 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={2}>
141 <Box flexShrink={0}>
142 <Text dimColor>{label}</Text>
143 </Box>
144 {aside}
145 </Box>
146 {content}
147 </Box>
148 )
149 }
150
151 return (
152 <Box flexDirection="row" gap={2} alignItems={isCentred ? 'center' : 'flex-start'}>
153 <Box flexShrink={0} width={LABEL_WIDTH}>
154 <Text dimColor>{label}</Text>
155 </Box>
156 <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
157 {content}
158 </Box>
159 {aside && <Box flexShrink={0}>{aside}</Box>}
160 </Box>
161 )
162}
163hooks/skills.tsx 772 lines1import { atom, read, update } from 'claude-code'
2import type { CommandInfo, ElementTable, EngineInterface, On } from 'claude-code'
3
4import type { PaneFilter, SkillEntry, SkillKind, Uses } from '../types'
5import { openPane } from './pane.js'
6import { LABEL_WIDTH, SECTION_GAP, cardFor, collapsedList, isActive, section } from './shared.js'
7import type { View } from './shared.js'
8
9type Chip = { name: string; label: string; isPinned: boolean }
10
11const pins = atom({ plugin: 'clawd-watch', key: 'pins' } as const, [])
12const uses = atom({ plugin: 'clawd-watch', key: 'uses' } as const, {})
13const skills = atom({ plugin: 'clawd-watch', key: 'skills' } as const, [])
14const isInline = atom({ plugin: 'clawd-watch', key: 'isInline' } as const, false)
15const hidden = atom({ plugin: 'clawd-watch', key: 'hidden' } as const, [])
16const filter = atom({ plugin: 'clawd-watch', key: 'filter' } as const, 'all')
17const query = atom({ plugin: 'clawd-watch', key: 'query' } as const, '')
18const confirming = atom({ plugin: 'clawd-watch', key: 'confirming' } as const, null)
19
20const DOCK_LABEL = 'Favourite skills'
21const STORE_PINS = 'pins'
22const STORE_USES = 'uses'
23const STORE_KNOWN = 'knownSkills'
24const STORE_HIDDEN = 'hidden'
25
26/** Where a removed skill goes, under the home folder, so it can be moved back. */
27const REMOVED_DIR = '.claude/clawd-watch/removed-skills'
28const REMOVED_NOTE = `Skills removed from Claude Code by Clawd Watch's skill dock.
29To restore one, move it back and drop the date from its name:
30a folder into ~/.claude/skills (or the project's .claude/skills),
31a .md file into ~/.claude/commands. Then run /reload-skills.
32`
33/** What \`npx skills add\` records: installed everywhere, and in this project. */
34const SKILL_LOCKS = { home: '.agents/.skill-lock.json', project: 'skills-lock.json' }
35/**
36 * A skill or command name, with \`:\` between folders, no part starting with a dot (so never \`..\`);
37 * anything else is never looked up on disk, let alone moved.
38 */
39export const SAFE_NAME = /^[\w-][\w.-]*(:[\w-][\w.-]*)*$/
40const KIND_LABEL: Record<SkillKind, string> = {
41 builtin: 'Built-in',
42 mine: 'Mine',
43 installed: 'Installed',
44}
45/** Clawd Watch's own commands (/watch, /dock, /handoff, /queue) aren't skills; nor are the old mods'. */
46const CLAWD_WATCH_MODS = ['clawd-watch', 'session-hud', 'message-queue', 'skill-dock']
47const FILTERS: readonly PaneFilter[] = ['all', 'builtin', 'mine', 'installed', 'hidden']
48
49const DAY = 86_400_000
50/** A use counts half as much each week it ages. */
51const HALF_LIFE = 7 * DAY
52/** "Most used" counts the last WINDOW_DAYS days; older uses are forgotten after KEEP_DAYS. */
53const WINDOW_DAYS = 30
54const WINDOW = WINDOW_DAYS * DAY
55const KEEP = 60 * DAY
56const MAX_USES = 60
57const DOCK_FREQUENT = 5
58const PANE_FREQUENT = 10
59const LONG_TOAST_MS = 8000
60
61/** Cells a chip takes beyond its label (a terminal text chip, a drawn button). */
62const CHIP_PADDING_TEXT = 2
63const CHIP_PADDING_BUTTON = 5
64/** Cells for the "│" between pinned and frequent chips. */
65const SEPARATOR_CELLS = 3
66/** Cells the chip row leaves free: the gap before "All skills" and the band's edge. */
67const ROW_SLACK = 4
68/** Gap after the label column. */
69const LABEL_GAP = 2
70
71/** Copies of the stored pins, uses and known built-in skills, for hooks that run often. */
72const local: {
73 pins: string[]
74 uses: Uses
75 known: string[]
76 hidden: string[]
77 /** Skills removed this session; the engine lists them until its skills reload. */
78 removed: string[]
79 monthCounts: Record<string, number>
80 cwd?: string
81 loaded?: Promise<void>
82} = { pins: [], uses: {}, known: [], hidden: [], removed: [], monthCounts: {} }
83
84export function score(times: readonly number[] = [], at: number): number {
85 return times.reduce((sum, t) => sum + Math.pow(0.5, (at - t) / HALF_LIFE), 0)
86}
87
88export function recentCount(times: readonly number[] = [], at: number): number {
89 return times.filter(t => at - t <= WINDOW).length
90}
91
92/** The most-used skills that are not pinned: most uses in the window first, recent use breaking ties. */
93export function frequent(
94 list: readonly SkillEntry[],
95 pinned: readonly string[],
96 used: Uses,
97 at: number,
98 limit: number,
99): SkillEntry[] {
100 return list
101 .filter(s => !pinned.includes(s.name) && recentCount(used[s.name], at) > 0)
102 .sort(
103 (a, b) =>
104 recentCount(used[b.name], at) - recentCount(used[a.name], at) ||
105 score(used[b.name], at) - score(used[a.name], at),
106 )
107 .slice(0, limit)
108}
109
110/** The chips that fit in `room` cells, in order; the first unpinned one after a pinned one also pays for the separator. */
111export function fitChips(chips: readonly Chip[], room: number, padding: number): Chip[] {
112 const fitted: Chip[] = []
113 let left = room
114
115 for (const chip of chips) {
116 const separator = !chip.isPinned && fitted.at(-1)?.isPinned ? SEPARATOR_CELLS : 0
117 const width = chip.label.length + padding + separator
118 if (width > left) continue
119 fitted.push(chip)
120 left -= width
121 }
122
123 return fitted
124}
125
126/** What kind of skill a command is, or undefined when it is not a skill. */
127export function kindOf(
128 info: CommandInfo,
129 known: readonly string[],
130): { kind: SkillKind; origin?: string } | undefined {
131 if (info.plugin !== undefined && CLAWD_WATCH_MODS.includes(info.plugin)) return undefined
132 if (info.source === 'user') return { kind: 'mine' }
133 if (info.source === 'plugin') return { kind: 'installed', origin: info.plugin ?? 'a plugin' }
134 if (!known.includes(info.name)) return undefined
135
136 return info.source === 'mcp' ? { kind: 'installed', origin: 'MCP' } : { kind: 'builtin' }
137}
138
139/** The short tag a skill carries in the lists: Built-in, Mine, or where it was installed from. */
140export function tagOf(s: SkillEntry): string {
141 return s.kind === 'installed' ? (s.origin ?? KIND_LABEL.installed) : KIND_LABEL[s.kind]
142}
143
144/** Whether a skill shows under a filter and a search. */
145export function matches(
146 s: SkillEntry,
147 view: { filter: PaneFilter; query: string; hidden: readonly string[] },
148): boolean {
149 const isHidden = view.hidden.includes(s.name)
150 if (view.filter === 'hidden' ? !isHidden : isHidden) return false
151 if (view.filter !== 'all' && view.filter !== 'hidden' && s.kind !== view.filter) return false
152 const words = view.query.trim().toLowerCase()
153
154 return (
155 words === '' || [s.name, s.description, s.origin ?? ''].join(' ').toLowerCase().includes(words)
156 )
157}
158
159/** The name without a plugin's namespace: `anthropic-skills:pdf` reads `pdf`. */
160function short(name: string): string {
161 return name.includes(':') ? name.slice(name.lastIndexOf(':') + 1) : name
162}
163
164function countRecent(used: Uses, at: number): Record<string, number> {
165 return Object.fromEntries(
166 Object.entries(used).map(([name, times]) => [name, recentCount(times, at)]),
167 )
168}
169
170async function stored<T>($: EngineInterface, key: string, fallback: T): Promise<T> {
171 const value = await $.store.get(key)
172
173 return value === undefined || value === null ? fallback : (value as T)
174}
175
176/** Loads what was saved, once per load of the mod. */
177function ensureLoaded($: EngineInterface): Promise<void> {
178 local.loaded ??= loadSaved($)
179 return local.loaded
180}
181
182async function loadSaved($: EngineInterface) {
183 const [savedPins, savedUses, known, savedHidden, at] = await Promise.all([
184 stored<string[]>($, STORE_PINS, []),
185 stored<Uses>($, STORE_USES, {}),
186 stored<string[]>($, STORE_KNOWN, []),
187 stored<string[]>($, STORE_HIDDEN, []),
188 $.clock.now(),
189 ])
190 Object.assign(local, {
191 pins: savedPins,
192 uses: savedUses,
193 known,
194 hidden: savedHidden,
195 monthCounts: countRecent(savedUses, at),
196 })
197 await Promise.all([
198 update($, pins, () => savedPins),
199 update($, uses, () => savedUses),
200 update($, hidden, () => savedHidden),
201 ])
202}
203
204async function homeDir($: EngineInterface): Promise<string | undefined> {
205 const home = await $.env.get('HOME').catch(() => undefined)
206
207 return home ?? (await $.env.get('USERPROFILE').catch(() => undefined))
208}
209
210/** The skills \`npx skills add\` installed, by name, with the repository each came from. */
211async function installedFrom($: EngineInterface, home?: string): Promise<Record<string, string>> {
212 const files = [
213 home && `${home}/${SKILL_LOCKS.home}`,
214 local.cwd && `${local.cwd}/${SKILL_LOCKS.project}`,
215 ].filter((path): path is string => Boolean(path))
216 const from: Record<string, string> = {}
217 for (const file of files) {
218 const lock = await $.fs
219 .read(file)
220 .then(text => JSON.parse(text) as { skills?: Record<string, { source?: unknown }> })
221 .catch(() => undefined)
222 for (const [name, entry] of Object.entries(lock?.skills ?? {})) {
223 if (typeof entry?.source === 'string') from[name] = entry.source
224 }
225 }
226
227 return from
228}
229
230/** Where one of your skills lives: its folder, or a command's file; the project's first. */
231async function locate($: EngineInterface, roots: readonly string[], name: string) {
232 if (!SAFE_NAME.test(name)) return undefined
233 for (const root of roots) {
234 const folder = `${root}/skills/${name}`
235 if (await $.fs.exists(`${folder}/SKILL.md`)) return folder
236 const file = `${root}/commands/${name.split(':').join('/')}.md`
237 if (await $.fs.exists(file)) return file
238 }
239
240 return undefined
241}
242
243async function refreshSkills($: EngineInterface) {
244 const home = await homeDir($)
245 const roots = [local.cwd && `${local.cwd}/.claude`, home && `${home}/.claude`].filter(
246 (root): root is string => Boolean(root),
247 )
248 const [infos, lock] = await Promise.all([$.command.list(), installedFrom($, home)])
249 const list: SkillEntry[] = []
250 for (const info of infos) {
251 const kind = kindOf(info, local.known)
252 if (!kind || local.removed.includes(info.name)) continue
253 const entry: SkillEntry = { name: info.name, description: info.description, ...kind }
254 if (info.source === 'user') {
255 const path = await locate($, roots, info.name).catch(() => undefined)
256 if (path) entry.path = path
257 if (lock[info.name]) Object.assign(entry, { kind: 'installed', origin: lock[info.name] })
258 }
259 list.push(entry)
260 }
261
262 await update($, skills, () => list)
263}
264
265async function recordUse($: EngineInterface, name: string) {
266 await ensureLoaded($)
267 const at = await $.clock.now()
268 const next = await update($, uses, all => {
269 const kept = (all[name] ?? []).filter(t => at - t <= KEEP)
270
271 return { ...all, [name]: [...kept, at].slice(-MAX_USES) }
272 })
273 local.uses = next
274 local.monthCounts = countRecent(next, at)
275
276 // Built-in skills look like any other built-in command until they are used once.
277 const isNewSkill = !local.known.includes(name)
278 if (isNewSkill) local.known = [...local.known, name]
279 await Promise.all([
280 $.store.set(STORE_USES, next),
281 isNewSkill ? $.store.set(STORE_KNOWN, local.known) : undefined,
282 ])
283 if (isNewSkill) await refreshSkills($)
284
285 $.ui.invalidate('command.describe')
286}
287
288async function togglePin($: EngineInterface, name: string): Promise<boolean> {
289 local.pins = await update($, pins, list =>
290 list.includes(name) ? list.filter(n => n !== name) : [...list, name],
291 )
292 await $.store.set(STORE_PINS, local.pins)
293 $.ui.invalidate('command.describe')
294
295 return local.pins.includes(name)
296}
297
298/** Hides a skill from the dock and the pane, or shows it again; a hidden pin is unpinned. */
299async function toggleHidden($: EngineInterface, name: string): Promise<boolean> {
300 local.hidden = await update($, hidden, list =>
301 list.includes(name) ? list.filter(n => n !== name) : [...list, name],
302 )
303 const isHidden = local.hidden.includes(name)
304 await $.store.set(STORE_HIDDEN, local.hidden)
305 if (isHidden && local.pins.includes(name)) await togglePin($, name)
306
307 return isHidden
308}
309
310/** Moves one of your skills out of Claude Code, into a folder it can be restored from. */
311async function removeSkill($: EngineInterface, s: SkillEntry) {
312 await update($, confirming, () => null)
313 const home = await homeDir($)
314 if (!s.path || !home) {
315 $.ui.toast(`Couldn't find where /${short(s.name)} lives, so it was left alone.`)
316 return
317 }
318
319 const stamp = new Date(await $.clock.now()).toISOString().slice(0, 19).replace(/[:T]/g, '-')
320 const folder = `${home}/${REMOVED_DIR}`
321 const isFile = s.path.endsWith('.md')
322 const target = `${folder}/${s.name.replace(/:/g, '-')}-${stamp}${isFile ? '.md' : ''}`
323 await $.fs.write(`${folder}/README.txt`, REMOVED_NOTE)
324 const isWindows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
325 const moved = await $.process
326 .run(isWindows ? ['cmd', '/c', 'move', s.path, target] : ['mv', s.path, target])
327 .catch((error: unknown) => ({ exitCode: 1, stderr: String(error) }))
328 if (moved.exitCode !== 0) {
329 $.ui.toast(`Couldn't remove /${short(s.name)}: ${moved.stderr.trim() || 'the move failed'}`)
330 return
331 }
332
333 local.removed = [...local.removed, s.name]
334 if (local.pins.includes(s.name)) await togglePin($, s.name)
335 await update($, skills, list => list.filter(one => one.name !== s.name))
336 $.ui.toast(
337 `Removed /${short(s.name)}. It's in ~/${REMOVED_DIR} if you want it back; run /reload-skills to drop it from the / menu.`,
338 { timeoutMs: LONG_TOAST_MS },
339 )
340}
341
342async function insertSkill($: EngineInterface, name: string) {
343 const draft = (await $.prompt.read()).text
344 // Swap a slash command already at the front for this one, keep the rest of the draft.
345 const rest = draft.replace(/^\/[\w:.-]+\s*/, '').trimStart()
346 const filled = await $.prompt.fill({ text: `/${name} ${rest}`, mode: 'replace' })
347
348 if (!filled.isFilled) $.ui.toast(`Type /${name} to use it; the prompt box is busy.`)
349}
350
351/** Opens Clawd Watch's pane on all skills; where no pane can be placed, the list opens inline. */
352async function openAll($: EngineInterface) {
353 await refreshSkills($)
354 const isPlaced = await openPane($, 'skills')
355 await update($, isInline, () => !isPlaced)
356}
357
358const FILTER_LABEL: Record<PaneFilter, string> = {
359 all: 'All',
360 builtin: KIND_LABEL.builtin,
361 mine: KIND_LABEL.mine,
362 installed: KIND_LABEL.installed,
363 hidden: 'Hidden',
364}
365
366/** Everything the skill lists draw from, read together. */
367async function listState($: EngineInterface) {
368 const [pinned, used, list, hiddenList, filterBy, search, confirmingId, collapsed, at] =
369 await Promise.all([
370 read($, pins),
371 read($, uses),
372 read($, skills),
373 read($, hidden),
374 read($, filter),
375 read($, query),
376 read($, confirming),
377 collapsedList($),
378 $.clock.now(),
379 ])
380
381 return { pinned, used, list, hiddenList, filterBy, search, confirmingId, collapsed, at }
382}
383
384type ListState = Awaited<ReturnType<typeof listState>>
385
386function skillRow(
387 $: EngineInterface,
388 t: ElementTable,
389 list: string,
390 s: SkillEntry,
391 row: {
392 isPinned: boolean
393 isHidden: boolean
394 isConfirming: boolean
395 isText: boolean
396 meta?: string
397 },
398) {
399 const { Box, Text, Button } = t
400 const plain = row.isText || undefined
401 // Where there's a pointer, a row's action shows only while it's hovered.
402 const reveal = row.isText ? {} : { display: 'none' as const, hover: { display: 'flex' as const } }
403 const action = row.isHidden
404 ? { label: 'Unhide', onPress: () => toggleHidden($, s.name) }
405 : s.path
406 ? { label: 'Remove', onPress: () => update($, confirming, () => `${list}:${s.name}`) }
407 : {
408 label: 'Hide',
409 onPress: async () => {
410 await toggleHidden($, s.name)
411 $.ui.toast(`Hid /${short(s.name)}. It's under Hidden in the skills pane.`)
412 },
413 }
414
415 return (
416 <Box key={`${list}-row-${s.name}`} flexDirection="column">
417 <Box flexDirection="row" gap={1} alignItems="center">
418 {!row.isHidden && (
419 <Button
420 key={`${list}-star-${s.name}`}
421 plain
422 dimColor={!row.isPinned}
423 label={row.isPinned ? '★' : '☆'}
424 onPress={async () => {
425 const nowPinned = await togglePin($, s.name)
426 $.ui.toast(`${nowPinned ? 'Pinned' : 'Unpinned'} /${short(s.name)}`)
427 }}
428 />
429 )}
430 <Box flexGrow={1} flexShrink={1} minWidth={0}>
431 <Button
432 key={`${list}-use-${s.name}`}
433 plain
434 label={short(s.name)}
435 onPress={() => insertSkill($, s.name)}
436 />
437 </Box>
438 {row.meta !== undefined && <Text dimColor>{row.meta}</Text>}
439 <Box flexShrink={1} minWidth={0}>
440 <Text dimColor wrap="truncate-end">
441 {tagOf(s)}
442 </Text>
443 </Box>
444 <Box flexShrink={0} {...reveal}>
445 <Button
446 key={`${list}-act-${s.name}`}
447 plain
448 dimColor
449 label={action.label}
450 onPress={action.onPress}
451 />
452 </Box>
453 </Box>
454 {row.isConfirming && (
455 <Box flexDirection="column" paddingLeft={2} marginTop={1} marginBottom={1}>
456 <Text wrap="wrap">
457 {`Remove /${short(s.name)} from Claude Code? It moves to ~/${REMOVED_DIR}, where you can restore it.`}
458 </Text>
459 <Box flexDirection="row" gap={1} marginTop={1}>
460 <Button
461 key={`${list}-remove-${s.name}`}
462 plain={plain}
463 variant="primary"
464 label="Remove"
465 onPress={() => removeSkill($, s)}
466 />
467 <Button
468 key={`${list}-cancel-${s.name}`}
469 plain={plain}
470 role="dismiss"
471 label="Cancel"
472 onPress={() => update($, confirming, () => null)}
473 />
474 </Box>
475 </Box>
476 )}
477 </Box>
478 )
479}
480
481/**
482 * The skills pane, or the list under the dock: a search, filters by kind, then pinned, most used
483 * and every skill available here (or the hidden ones). The desktop pane draws each as a card.
484 */
485function skillList(
486 $: EngineInterface,
487 t: ElementTable,
488 state: ListState,
489 view: { isText: boolean; hasCards: boolean },
490) {
491 const { Box, Text, Button } = t
492 const { list, pinned, used, at, hiddenList, filterBy, search, confirmingId, collapsed } = state
493 const byName = new Map(list.map(s => [s.name, s]))
494 const entry = (name: string): SkillEntry =>
495 byName.get(name) ?? { name, description: '', kind: 'builtin' }
496 const shows = (s: SkillEntry) =>
497 matches(s, { filter: filterBy, query: search, hidden: hiddenList })
498 const isNarrowed = filterBy !== 'all' || search.trim() !== ''
499 const row = (listName: string, s: SkillEntry, meta?: string) =>
500 skillRow($, t, listName, s, {
501 isPinned: pinned.includes(s.name),
502 isHidden: hiddenList.includes(s.name),
503 isConfirming: confirmingId === `${listName}:${s.name}`,
504 isText: view.isText,
505 ...(meta === undefined ? {} : { meta }),
506 })
507
508 const counts: Record<PaneFilter, number> = {
509 all: list.filter(s => !hiddenList.includes(s.name)).length,
510 builtin: 0,
511 mine: 0,
512 installed: 0,
513 hidden: list.filter(s => hiddenList.includes(s.name)).length,
514 }
515 for (const s of list) if (!hiddenList.includes(s.name)) counts[s.kind] += 1
516 const filters = FILTERS.filter(f => f !== 'hidden' || counts.hidden > 0 || filterBy === 'hidden')
517
518 const group = (name: string, detail: string, rows: JSX.Element[], emptyText: string) => {
519 if (rows.length === 0 && isNarrowed) return null
520 const body =
521 rows.length > 0 ? (
522 <Box flexDirection="column">{rows}</Box>
523 ) : (
524 <Text dimColor italic>
525 {emptyText}
526 </Text>
527 )
528 if (view.hasCards) {
529 return section(t, name, body, {
530 isStacked: true,
531 aside: <Text dimColor>{detail}</Text>,
532 card: cardFor($, { isText: false, collapsed }, name),
533 })
534 }
535
536 return (
537 <Box flexDirection="column">
538 <Box flexDirection="row" justifyContent="space-between">
539 <Text dimColor bold>
540 {name.toUpperCase()}
541 </Text>
542 <Text dimColor>{detail}</Text>
543 </Box>
544 {body}
545 </Box>
546 )
547 }
548
549 const sorted = (entries: SkillEntry[]) =>
550 [...entries].sort((a, b) => short(a.name).localeCompare(short(b.name)))
551 const groups =
552 filterBy === 'hidden'
553 ? [
554 group(
555 'Hidden',
556 `${counts.hidden}`,
557 sorted(list.filter(shows)).map(s => row('hidden', s)),
558 '',
559 ),
560 ]
561 : [
562 group(
563 'Pinned',
564 pinned.length ? `${pinned.length}` : '',
565 pinned
566 .map(entry)
567 .filter(shows)
568 .map(s => row('pinned', s)),
569 'Click ☆ next to a skill to pin it.',
570 ),
571 group(
572 'Most used',
573 `last ${WINDOW_DAYS} days`,
574 frequent(list.filter(shows), [], used, at, PANE_FREQUENT).map(s =>
575 row('used', s, `${recentCount(used[s.name], at)}×`),
576 ),
577 'Skills you use will show up here.',
578 ),
579 group(
580 'Available here',
581 `${list.filter(shows).length}`,
582 sorted(list.filter(shows)).map(s => row('all', s)),
583 'No skills here yet.',
584 ),
585 ]
586 const shown = groups.filter(Boolean)
587
588 return (
589 <Box flexDirection="column" gap={SECTION_GAP}>
590 {'Input' in t && (
591 <t.Input
592 key="skills-search"
593 placeholder="Search skills"
594 value={search}
595 onInput={value => void update($, query, () => value)}
596 onSubmit={value => void update($, query, () => value)}
597 />
598 )}
599 <Box flexDirection="row" flexWrap="wrap" gap={2}>
600 {filters.map(f => (
601 <Button
602 key={`skills-filter-${f}`}
603 plain
604 dimColor={f !== filterBy}
605 label={`${FILTER_LABEL[f]} ${counts[f]}`}
606 onPress={() => update($, filter, () => f)}
607 />
608 ))}
609 </Box>
610 {shown}
611 {shown.length === 0 && (
612 <Text dimColor italic>
613 No skills match.
614 </Text>
615 )}
616 </Box>
617 )
618}
619
620function chipRow($: EngineInterface, t: ElementTable, chips: Chip[], isText: boolean) {
621 const { Text, Button } = t
622 const row: JSX.Element[] = []
623
624 chips.forEach((chip, index) => {
625 // On the desktop the stars mark the pins; a bar there could wrap to the start of a row.
626 if (isText && !chip.isPinned && chips[index - 1]?.isPinned) row.push(<Text dimColor>│</Text>)
627 row.push(
628 <Button
629 key={`dock-${chip.isPinned ? 'pin' : 'freq'}-${chip.name}`}
630 // In the terminal, chips are plain text spaced apart; brackets around each read as noise.
631 // On the desktop only pins are chips, so they read apart from the skills used often.
632 plain={isText || !chip.isPinned || undefined}
633 dimColor={!chip.isPinned}
634 label={chip.label}
635 onPress={() => insertSkill($, chip.name)}
636 />,
637 )
638 })
639
640 return row
641}
642
643/** The hooks behind the Favourite skills section, the skills list and /dock. */
644export function registerSkills(on: On, showIn: string) {
645 on('skill.prompt', async ($, e, next) => {
646 const result = await next(e)
647 $.clock.after(0, () => void recordUse($, e.skill))
648
649 return result
650 })
651
652 on('command.run', { command: 'dock' }, async ($, e) => {
653 if (!(await isActive($, showIn))) {
654 return { text: `Clawd Watch is set to show in the ${showIn} only. Change it under /config.` }
655 }
656
657 const [verb, name] = (e.args ?? '').trim().split(/\s+/)
658 const skill = (name ?? '').replace(/^\//, '')
659 if ((verb === 'pin' || verb === 'unpin') && skill) {
660 if ((verb === 'pin') !== local.pins.includes(skill)) await togglePin($, skill)
661
662 return { text: `${verb === 'pin' ? 'Pinned' : 'Unpinned'} /${skill}.` }
663 }
664 if ((verb === 'hide' || verb === 'unhide') && skill) {
665 if ((verb === 'hide') !== local.hidden.includes(skill)) await toggleHidden($, skill)
666
667 return { text: `${verb === 'hide' ? 'Hid' : 'Unhid'} /${skill}.` }
668 }
669
670 await openAll($)
671
672 return { text: 'Opened your skills.' }
673 })
674
675 // Runs once per command the menu lists, so it reads the local copies rather than the store.
676 on('command.describe', async ($, e, next) => {
677 const result = await next(e)
678 const isPinned = local.pins.includes(e.command)
679 const count = local.monthCounts[e.command] ?? 0
680 if ((!isPinned && count === 0) || !(await isActive($, showIn))) return result
681
682 const note = isPinned ? '★ pinned' : `↻ ${count}× in ${WINDOW_DAYS} days`
683
684 return { ...result, description: `${note} · ${result.description}` }
685 })
686}
687
688/** The Favourite skills section: pinned and most-used skills, a click from the prompt. */
689export async function skillsSections(
690 $: EngineInterface,
691 t: ElementTable,
692 view: View,
693): Promise<JSX.Element[]> {
694 const [state, inline] = await Promise.all([listState($), read($, isInline)])
695 const { pinned, used, list, hiddenList, at } = state
696 const { Box, Text, Button } = t
697 const { isText, isStacked } = view
698
699 const allLabel = inline ? 'Close' : `All skills (${list.length - hiddenList.length}) ›`
700 const shown = list.filter(s => !hiddenList.includes(s.name))
701 const candidates = [
702 ...pinned
703 .filter(name => !hiddenList.includes(name))
704 .map(name => ({ name, label: `★ ${short(name)}`, isPinned: true })),
705 ...frequent(shown, pinned, used, at, DOCK_FREQUENT).map(s => ({
706 name: s.name,
707 label: short(s.name),
708 isPinned: false,
709 })),
710 ]
711 // The desktop app wraps the chips, so every pin shows. The terminal keeps one row of the
712 // chips that fit; the rest are a click away in All skills.
713 const room =
714 view.columns - (isStacked ? 0 : LABEL_WIDTH + LABEL_GAP + allLabel.length) - ROW_SLACK
715 const chips = isText ? fitChips(candidates, room, CHIP_PADDING_TEXT) : candidates
716 const allButton = (
717 <Button
718 key="dock-all"
719 plain
720 dimColor
721 label={allLabel}
722 onPress={() => (inline ? update($, isInline, () => false) : openAll($))}
723 />
724 )
725
726 return [
727 section(
728 t,
729 DOCK_LABEL,
730 <Box flexDirection="column">
731 <Box
732 flexDirection="row"
733 flexWrap={isText ? 'nowrap' : 'wrap'}
734 gap={isText ? 2 : 1}
735 alignItems="center"
736 overflow="hidden"
737 >
738 {chips.length === 0 && <Text dimColor>Pin or use a skill and it shows up here</Text>}
739 {chipRow($, t, chips, isText)}
740 </Box>
741 {inline && (
742 <Box flexDirection="column" marginTop={1}>
743 {skillList($, t, state, { isText, hasCards: false })}
744 </Box>
745 )}
746 </Box>,
747 { isStacked, aside: allButton, isCentred: !inline, card: cardFor($, view, DOCK_LABEL) },
748 ),
749 ]
750}
751
752/** Every skill, searchable and filtered by kind: the pane's skills view. */
753export async function allSkills($: EngineInterface, t: ElementTable, view: View) {
754 return skillList($, t, await listState($), { isText: view.isText, hasCards: !view.isText })
755}
756
757/** At session start: /dock, what was saved, and the skills this session has. */
758export async function startSkills($: EngineInterface, cwd: string) {
759 local.cwd = cwd
760 await Promise.all([
761 $.command
762 .register({
763 name: 'dock',
764 description: 'Show all your skills in Clawd Watch: search, pin, hide or remove them',
765 argumentHint: '[pin | unpin | hide | unhide <skill>]',
766 })
767 .catch(() => undefined),
768 ensureLoaded($),
769 ])
770 await refreshSkills($)
771}
772hooks/subagents.tsx 928 lines1import { atom, read, update } from 'claude-code'
2import type {
3 ElementTable,
4 EngineInterface,
5 On,
6 RenderSurface,
7 Timer,
8 TurnCompleteInput,
9} from 'claude-code'
10
11import type { AgentRun, Handoff } from '../types'
12import {
13 ACCENT,
14 COMPACT_COLUMNS,
15 LABEL_WIDTH,
16 cardFor,
17 isActive,
18 plural,
19 section,
20} from './shared.js'
21import type { Card, View } from './shared.js'
22
23type ReadyHandoff = Extract<Handoff, { phase: 'ready' }>
24type Sample = { type: string; ms: number; steps: number; guessMs?: number; guessSteps?: number }
25/** How the sections draw: text or graphics, name above or beside, narrow or not, bar length. */
26type Style = { isText: boolean; isStacked: boolean; isCompact: boolean; barCells: number }
27
28const agents = atom({ plugin: 'clawd-watch', key: 'agents' } as const, [])
29const now = atom({ plugin: 'clawd-watch', key: 'now' } as const, 0)
30const handoff = atom({ plugin: 'clawd-watch', key: 'handoff' } as const, null)
31
32// Colors that read on both light and dark backgrounds.
33const SUCCESS = '#5fa865'
34const ERROR = '#d9534f'
35const BAR_TRACK = '#8b8b8b'
36
37const TICK_MS = 1000
38const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
39/** How long a finished subagent stays on screen. */
40const LINGER_MS = 8000
41const MAX_AGENT_ROWS = 12
42/** Rows the band spends on things other than agent rows, and the rows each agent takes. */
43const BAND_CHROME_ROWS = 3
44const ROWS_PER_AGENT = { terminal: 2, graphical: 1 }
45/** Cells for the time at the end of a desktop row, so the bars and times line up. */
46const TIME_COLUMN = 18
47/** Check running rows against the engine's agent list this often, in ticks. */
48const RECONCILE_EVERY_TICKS = 10
49
50/** Past runs the ETA model learns from, and how many of a type it needs before trusting them. */
51const HISTORY_SIZE = 60
52const MIN_SAMPLES = 3
53/** What a subagent takes before any run of its type has been seen. */
54const DEFAULT_MS: Record<string, number> = {
55 Explore: 90_000,
56 Plan: 120_000,
57 'general-purpose': 300_000,
58}
59const DEFAULT_STEPS: Record<string, number> = { Explore: 20, Plan: 15, 'general-purpose': 40 }
60const FALLBACK_MS = 180_000
61const FALLBACK_STEPS = 25
62
63/** Claude sizes each subagent's task from its instructions, with a small, fast model. */
64const SIZE_MODEL = 'haiku'
65const SIZE_TIMEOUT_MS = 20_000
66const SIZE_MAX_TOKENS = 200
67/** How much of a subagent's instructions the sizing call reads. */
68const SIZE_PROMPT_CHARS = 6000
69/** Bounds on a guess, and on how far learned corrections may stretch or shrink one. */
70const GUESS_MINUTES = { min: 0.2, max: 180 }
71const GUESS_STEPS = { min: 1, max: 1000 }
72const CORRECTION = { min: 0.25, max: 4 }
73const SIZE_RULES = `You estimate how long a Claude Code subagent will take to finish its task. A subagent works alone with tools: it reads and searches files, edits code and runs commands, one tool call at a time, then reports back.
74
75Judge the size of the work in the instructions: how many files it must read or change, whether it must run builds or tests, how open-ended it is. For reference, one tool call takes about 5 to 20 seconds. A quick lookup is about 1 to 2 minutes and 10 tool calls. Reading through a feature to explain it is about 3 to 6 minutes and 20 to 50 calls. Implementing and testing a change is about 8 to 25 minutes and 50 to 150 calls.
76
77Reply with exactly this and nothing else:
78<minutes>N</minutes><steps>M</steps>
79N is wall-clock minutes (decimals allowed) and M is the number of tool calls.`
80
81/** When to ask Claude whether the next work fits a fresh session. */
82const CHECK_AFTER_TURNS = 4
83const CHECK_AT_CONTEXT_PERCENT = 30
84const CHECK_ALWAYS_AFTER_TURNS = 12
85const CHECK_DELAY_MS = 1500
86/** An unused suggestion clears after this many more turns. */
87const STALE_TURNS = 3
88
89const MIN_BAND_COLUMNS = 30
90const COLUMNS_PER_BAR_CELL = 6
91const BAR_MIN_CELLS = 10
92const BAR_MAX_CELLS = 20
93const BAR_PX_PER_CELL = 8
94const BAR_HEIGHT_PX = 6
95const LONG_TOAST_MS = 8000
96
97const STORE_HISTORY = 'agentHistory'
98const STORE_AUTO_SUGGEST = 'autoSuggest'
99
100const STATUS_LOOK: Record<AgentRun['status'], { glyph: string; tone: string; verb: string }> = {
101 running: { glyph: '●', tone: ACCENT, verb: '' },
102 done: { glyph: '✓', tone: SUCCESS, verb: 'finished in' },
103 failed: { glyph: '✗', tone: ERROR, verb: 'stopped after' },
104}
105
106const HANDOFF_RULES = `The starter prompt goes to a brand-new Claude Code session that has none of this conversation's context. It must stand alone: state the goal; what is already done (file paths, branch, commands, PRs); key decisions and constraints; what remains, as concrete next steps; and how to verify the result. Address the new session directly ("You are continuing..."), keep it tight, no preamble.`
107
108const HANDOFF_FORMAT = `<title>3 to 6 word title of the next work</title>
109<handoff>
110the starter prompt
111</handoff>`
112
113const AUTO_ASK = `Stop and do not continue the work. Judge one question: would the NEXT piece of work in this conversation go better in a fresh Claude Code session (cleared context) than here?
114Say yes only when the next work is a distinct task that needs little of the detail above (a new feature, a separate bug, moving from planning to a large implementation), or when this context is long and mostly no longer relevant to what comes next.
115Say no when we are mid-task, debugging something that depends on details above, waiting on an answer from the user, or the conversation is short.
116Reply in exactly this format and nothing else:
117<decision>yes or no</decision>
118<reason>one short sentence, under 12 words, saying why</reason>
119Only when the decision is yes, add after it:
120${HANDOFF_FORMAT}
121${HANDOFF_RULES}`
122
123function manualAsk(focus: string): string {
124 const target = focus
125 ? `Focus the handoff on: ${focus}`
126 : 'Focus on the next piece of work we were about to do.'
127
128 return `Stop and do not continue the work. Write a handoff so it can continue in a fresh session.
129${target}
130${HANDOFF_RULES}
131Reply in exactly this format and nothing else:
132${HANDOFF_FORMAT}`
133}
134
135const local: {
136 contextPercent: number
137 turnsSinceCheck: number
138 isChecking: boolean
139 wasLive: boolean
140 ticks: number
141 ticker?: Timer
142 history?: Sample[]
143} = { contextPercent: 0, turnsSinceCheck: 0, isChecking: false, wasLive: false, ticks: 0 }
144
145function tag(text: string, name: string): string | undefined {
146 const match = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(text)
147
148 return match?.[1]?.trim() || undefined
149}
150
151function median(values: number[]): number {
152 const sorted = [...values].sort((a, b) => a - b)
153 const mid = Math.floor(sorted.length / 2)
154
155 return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2
156}
157
158function clamp(value: number, low: number, high: number): number {
159 return Math.min(high, Math.max(low, value))
160}
161
162function duration(ms: number): string {
163 const s = Math.max(0, Math.round(ms / 1000))
164 if (s < 60) return `${s}s`
165 const m = Math.floor(s / 60)
166 if (m < 60) return `${m}m ${s % 60}s`
167
168 return `${Math.floor(m / 60)}h ${m % 60}m`
169}
170
171export function clockTime(ms: number): string {
172 try {
173 return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
174 } catch {
175 const d = new Date(ms)
176
177 return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
178 }
179}
180
181function basename(path: unknown): string {
182 return typeof path === 'string' ? (path.split('/').pop() ?? path) : ''
183}
184
185/** A few words on what a subagent's tool call is doing. */
186export function describeCall(call: Record<string, unknown>): string {
187 const tool = String(call.tool)
188 const quoted = (value: unknown) => (typeof value === 'string' ? `"${value}"` : '')
189
190 switch (tool) {
191 case 'Read':
192 return `Reading ${basename(call.file_path)}`
193 case 'Edit':
194 case 'MultiEdit':
195 case 'Write':
196 return `Editing ${basename(call.file_path)}`
197 case 'Grep':
198 return `Searching ${quoted(call.pattern)}`
199 case 'Glob':
200 return `Finding ${quoted(call.pattern)}`
201 case 'Bash':
202 return `Running ${String(call.description ?? call.command ?? '')}`
203 case 'WebFetch':
204 try {
205 return `Fetching ${new URL(String(call.url)).host}`
206 } catch {
207 return 'Fetching a page'
208 }
209 case 'WebSearch':
210 return `Searching the web for ${quoted(call.query)}`
211 case 'TodoWrite':
212 case 'TaskCreate':
213 case 'TaskUpdate':
214 return 'Updating its plan'
215 default:
216 return tool.startsWith('mcp__') ? `Using ${tool.split('__').slice(1).join(' ')}` : tool
217 }
218}
219
220/** Progress and time left for one run, from its elapsed time, steps and what similar runs took. */
221export function estimate(run: AgentRun, at: number) {
222 const elapsed = Math.max(0, (run.endedAt ?? at) - run.startedAt)
223 if (run.status !== 'running') return { elapsed, progress: 1, remaining: 0 }
224
225 // Time leads; steps only nudge, so a busy agent doesn't jump to the end early.
226 const raw = 0.75 * (elapsed / run.expectedMs) + 0.25 * Math.min(1, run.steps / run.expectedSteps)
227 // Linear until 85%, then it eases toward 97% instead of claiming it is done.
228 const eased = raw < 0.85 ? raw : 0.85 + 0.12 * (1 - Math.exp(-(raw - 0.85) * 2.5))
229 const progress = clamp(eased, 0.01, 0.97)
230 const projected = elapsed > 5000 && progress > 0.08 ? elapsed / progress : run.expectedMs
231 const total = Math.max(elapsed + 3000, 0.5 * run.expectedMs + 0.5 * projected)
232
233 return { elapsed, progress, remaining: total - elapsed }
234}
235
236function isShown(run: AgentRun, at: number): boolean {
237 return run.status === 'running' || at - (run.endedAt ?? at) < LINGER_MS
238}
239
240function isAgentTool(tool: string): boolean {
241 return tool === 'Agent' || tool === 'Task'
242}
243
244function readyHandoff(
245 source: ReadyHandoff['source'],
246 title: string,
247 reason: string,
248 prompt: string,
249): ReadyHandoff {
250 return { phase: 'ready', source, title, reason, prompt, turnsSince: 0 }
251}
252
253/** Redraws once a second while subagents are on screen, so times keep moving. */
254function startTicker($: EngineInterface) {
255 local.ticker ??= $.clock.every(TICK_MS, () => void tick($))
256}
257
258async function tick($: EngineInterface) {
259 const [at, list] = await Promise.all([$.clock.now(), read($, agents)])
260 const isLive = list.some(run => isShown(run, at))
261 if (isLive || local.wasLive) await update($, now, () => at)
262 local.wasLive = isLive
263
264 if (!isLive) {
265 local.ticker?.cancel()
266 local.ticker = undefined
267 return
268 }
269
270 local.ticks += 1
271 if (local.ticks % RECONCILE_EVERY_TICKS === 0) await reconcile($, list)
272}
273
274/** Ends rows whose subagent the engine says has stopped, should its last turn have gone unseen. */
275async function reconcile($: EngineInterface, list: readonly AgentRun[]) {
276 const watched = list.filter(run => run.status === 'running' && run.agentId !== undefined)
277 if (watched.length === 0) return
278
279 const infos = await $.agent.list().catch(() => [])
280 const statusById = new Map(infos.map(info => [info.id, info.status]))
281 for (const run of watched) {
282 const status = statusById.get(run.agentId!)
283 if (status === undefined || status === 'running') continue
284 await finish($, one => one.agentId === run.agentId, status === 'completed' ? 'done' : 'failed')
285 }
286}
287
288async function history($: EngineInterface): Promise<Sample[]> {
289 if (!local.history) {
290 const stored = await $.store.get(STORE_HISTORY)
291 local.history = Array.isArray(stored) ? (stored as Sample[]) : []
292 }
293
294 return local.history
295}
296
297async function remember($: EngineInterface, sample: Sample) {
298 local.history = [...(await history($)), sample].slice(-HISTORY_SIZE)
299 await $.store.set(STORE_HISTORY, local.history)
300}
301
302async function expectation($: EngineInterface, type: string) {
303 const past = await history($)
304 const same = past.filter(sample => sample.type === type)
305 const basis = same.length >= MIN_SAMPLES ? same : past.length >= MIN_SAMPLES ? past : []
306
307 return basis.length
308 ? {
309 expectedMs: median(basis.map(sample => sample.ms)),
310 expectedSteps: Math.max(1, median(basis.map(sample => sample.steps))),
311 }
312 : {
313 expectedMs: DEFAULT_MS[type] ?? FALLBACK_MS,
314 expectedSteps: DEFAULT_STEPS[type] ?? FALLBACK_STEPS,
315 }
316}
317
318/** Claude's guess at a task's minutes and tool calls, or undefined when the reply holds none. */
319export function parseSize(text: string): { ms: number; steps: number } | undefined {
320 const minutes = Number(tag(text, 'minutes'))
321 const steps = Number(tag(text, 'steps'))
322 if (!(minutes > 0) || !(steps > 0)) return undefined
323
324 return {
325 ms: clamp(minutes, GUESS_MINUTES.min, GUESS_MINUTES.max) * 60_000,
326 steps: Math.round(clamp(steps, GUESS_STEPS.min, GUESS_STEPS.max)),
327 }
328}
329
330/** How actual runs compared with Claude's guesses: the median ratio, 1 until there are enough. */
331export function correction(past: readonly Sample[], ratio: (sample: Sample) => number | undefined) {
332 const ratios = past.map(ratio).filter((r): r is number => r !== undefined && Number.isFinite(r))
333 if (ratios.length < MIN_SAMPLES) return 1
334
335 return clamp(median(ratios), CORRECTION.min, CORRECTION.max)
336}
337
338/** Asks Claude to size a subagent's task, then sets the run's expectations from its answer. */
339async function sizeRun($: EngineInterface, toolUseId: string, task: string) {
340 const reply = await $.model
341 .complete({
342 model: SIZE_MODEL,
343 system: SIZE_RULES,
344 prompt: task,
345 effort: 'low',
346 maxTokens: SIZE_MAX_TOKENS,
347 timeoutMs: SIZE_TIMEOUT_MS,
348 })
349 .catch(() => undefined)
350 const guess = reply?.isAnswered ? parseSize(reply.text) : undefined
351 const past = await history($)
352 // Without a guess, the expectations from past runs that the row started with stay.
353 const sized = guess && {
354 guessMs: guess.ms,
355 guessSteps: guess.steps,
356 expectedMs: guess.ms * correction(past, s => (s.guessMs ? s.ms / s.guessMs : undefined)),
357 expectedSteps: Math.max(
358 1,
359 Math.round(
360 guess.steps * correction(past, s => (s.guessSteps ? s.steps / s.guessSteps : undefined)),
361 ),
362 ),
363 }
364
365 await update($, agents, list =>
366 list.map(one => (one.toolUseId === toolUseId ? { ...one, ...sized, isSizing: false } : one)),
367 )
368}
369
370async function finish(
371 $: EngineInterface,
372 matches: (run: AgentRun) => boolean,
373 status: AgentRun['status'],
374) {
375 const [at, list] = await Promise.all([$.clock.now(), read($, agents)])
376 const run = list.find(one => one.status === 'running' && matches(one))
377 if (!run) return
378
379 await Promise.all([
380 update($, agents, all =>
381 all.map(one =>
382 one.toolUseId === run.toolUseId && one.status === 'running'
383 ? { ...one, status, endedAt: at }
384 : one,
385 ),
386 ),
387 update($, now, () => at),
388 ])
389
390 if (status === 'done') {
391 await remember($, {
392 type: run.type,
393 ms: at - run.startedAt,
394 steps: run.steps,
395 ...(run.guessMs ? { guessMs: run.guessMs, guessSteps: run.guessSteps } : {}),
396 })
397 }
398}
399
400async function writeHandoff($: EngineInterface, focus: string) {
401 const reply = await $.model.fork({ prompt: manualAsk(focus) }).catch((error: unknown) => ({
402 isAnswered: false as const,
403 reason: error instanceof Error ? error.message : 'the request failed',
404 }))
405 const prompt = reply.isAnswered ? (tag(reply.text, 'handoff') ?? reply.text.trim()) : undefined
406
407 if (!prompt) {
408 await update($, handoff, () => null)
409 const why = reply.isAnswered ? 'the reply was empty' : reply.reason
410 $.ui.toast(`Couldn't write a handoff (${why}).`)
411 return
412 }
413
414 const title = (reply.isAnswered && tag(reply.text, 'title')) || 'Continue in a fresh session'
415 await update($, handoff, () => readyHandoff('manual', title, '', prompt))
416}
417
418async function autoCheck($: EngineInterface) {
419 if (local.isChecking || (await read($, handoff))) return
420 local.isChecking = true
421
422 try {
423 const reply = await $.model.fork({ prompt: AUTO_ASK }).catch(() => undefined)
424 if (!reply?.isAnswered || tag(reply.text, 'decision')?.toLowerCase() !== 'yes') return
425
426 const prompt = tag(reply.text, 'handoff')
427 if (!prompt || (await read($, handoff))) return
428
429 const title = tag(reply.text, 'title') ?? 'Next task'
430 const reason = tag(reply.text, 'reason') ?? 'fits a fresh session'
431 await update($, handoff, () => readyHandoff('auto', title, reason, prompt))
432 $.ui.toast('The next task fits a fresh session: the handoff is ready above the prompt.')
433 } finally {
434 local.isChecking = false
435 }
436}
437
438async function startFresh($: EngineInterface, ready: ReadyHandoff, surface: RenderSurface) {
439 await update($, handoff, () => null)
440 await $.ui.copy({ text: ready.prompt, surface }).catch(() => undefined)
441
442 try {
443 await $.command.run({ command: 'clear' })
444 } catch {
445 await update($, handoff, () => ready)
446 $.ui.toast(
447 'Could not clear the session; the handoff is on your clipboard. Run /clear and paste it.',
448 { timeoutMs: LONG_TOAST_MS },
449 )
450 return
451 }
452
453 await $.prompt.submit({ text: ready.prompt, asUser: true })
454}
455
456async function copyHandoff($: EngineInterface, ready: ReadyHandoff, surface: RenderSurface) {
457 const copied = await $.ui.copy({ text: ready.prompt, surface })
458 $.ui.toast(copied.isCopied ? 'Handoff copied.' : 'Could not reach the clipboard.')
459}
460
461/** A progress bar: rounded SVG on graphical surfaces, a run of blocks in the terminal. */
462function meter(t: ElementTable, key: string, fraction: number, label: string, style: Style) {
463 const { Box, Text } = t
464 const f = clamp(fraction, 0, 1)
465
466 if (!style.isText && 'Svg' in t) {
467 const { Svg } = t
468 const width = style.barCells * BAR_PX_PER_CELL
469 // Not `h`: that name is the JSX factory.
470 const height = BAR_HEIGHT_PX
471 const filled = f > 0 ? Math.max(height, Math.round(width * f)) : 0
472 const rect = (length: number, paint: string) =>
473 `<rect width="${length}" height="${height}" rx="${height / 2}" ${paint}/>`
474 const source =
475 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
476 rect(width, `fill="${BAR_TRACK}" fill-opacity="0.25"`) +
477 (filled ? rect(filled, `fill="${ACCENT}"`) : '') +
478 `</svg>`
479
480 return <Svg key={key} source={source} alt={label} width={width} height={height} />
481 }
482
483 const filled = Math.round(f * style.barCells)
484
485 return (
486 <Box flexDirection="row">
487 <Text color={ACCENT}>{'━'.repeat(filled)}</Text>
488 <Text dimColor>{'─'.repeat(style.barCells - filled)}</Text>
489 </Box>
490 )
491}
492
493/** One line on graphical surfaces: the task and what it's doing, then its bar and time in columns. */
494function agentLine(t: ElementTable, run: AgentRun, at: number, style: Style) {
495 const { Box, Text } = t
496 const { elapsed, progress, remaining } = estimate(run, at)
497 const look = STATUS_LOOK[run.status]
498 const isRunning = run.status === 'running'
499 const time = !isRunning
500 ? `${look.verb} ${duration(elapsed)}`
501 : run.isSizing
502 ? 'estimating…'
503 : `~${clockTime(at + remaining)}`
504
505 return (
506 <Box flexDirection="row" alignItems="center" gap={2}>
507 <Box flexDirection="row" gap={1} flexGrow={1} flexShrink={1} minWidth={0}>
508 <Text color={look.tone}>{look.glyph}</Text>
509 <Box flexShrink={1} minWidth={0}>
510 <Text wrap="truncate-end" dimColor={!isRunning}>
511 {run.description}
512 </Text>
513 </Box>
514 {/* What it's doing gives way first when the row runs short. */}
515 {isRunning && (
516 <Box flexShrink={4} minWidth={0} paddingLeft={1}>
517 <Text dimColor wrap="truncate-end">
518 {run.activity}
519 </Text>
520 </Box>
521 )}
522 </Box>
523 {isRunning &&
524 meter(
525 t,
526 `bar-${run.toolUseId}`,
527 progress,
528 `about ${Math.round(progress * 100)}% done`,
529 style,
530 )}
531 <Box width={TIME_COLUMN} flexShrink={0} justifyContent="flex-end">
532 <Text dimColor={!isRunning || run.isSizing}>{time}</Text>
533 </Box>
534 </Box>
535 )
536}
537
538function agentRow(t: ElementTable, run: AgentRun, at: number, style: Style) {
539 // One line where there's room; a narrow pane gets the two-line row the terminal draws.
540 if (!style.isText && !style.isCompact) return agentLine(t, run, at, style)
541
542 const { Box, Text } = t
543 const { elapsed, progress, remaining } = estimate(run, at)
544 const look = STATUS_LOOK[run.status]
545 const isRunning = run.status === 'running'
546 // Braille spinners read as stray dots in a proportional font, so only the terminal spins.
547 const glyph =
548 isRunning && style.isText ? SPINNER[Math.floor(at / TICK_MS) % SPINNER.length] : look.glyph
549
550 return (
551 <Box flexDirection="column">
552 <Box flexDirection="row" justifyContent="space-between" gap={2}>
553 <Box flexDirection="row" gap={1} flexShrink={1} minWidth={0}>
554 <Text color={look.tone}>{glyph}</Text>
555 <Text wrap="truncate-end" dimColor={!isRunning}>
556 {run.description}
557 </Text>
558 </Box>
559 {isRunning ? (
560 run.isSizing ? (
561 <Text dimColor>estimating…</Text>
562 ) : (
563 <Text>{`done by ~${clockTime(at + remaining)}`}</Text>
564 )
565 ) : (
566 <Text dimColor>{`${look.verb} ${duration(elapsed)}`}</Text>
567 )}
568 </Box>
569 {isRunning && (
570 <Box flexDirection="row" gap={2} paddingLeft={2} alignItems="center">
571 {meter(
572 t,
573 `bar-${run.toolUseId}`,
574 progress,
575 `about ${Math.round(progress * 100)}% done`,
576 style,
577 )}
578 <Box flexShrink={1} minWidth={0}>
579 <Text dimColor wrap="truncate-end">
580 {run.activity}
581 </Text>
582 </Box>
583 </Box>
584 )}
585 </Box>
586 )
587}
588
589function subagentsSection(
590 t: ElementTable,
591 shown: AgentRun[],
592 at: number,
593 style: Style,
594 maxRows: number,
595 card?: Card,
596) {
597 const { Box, Text } = t
598 const sized = shown.filter(run => !run.isSizing)
599 const left = Math.max(0, ...sized.map(run => estimate(run, at).remaining))
600 const isRunning = shown.some(run => run.status === 'running')
601 const isEstimating = shown.some(run => run.status === 'running' && run.isSizing)
602 const rowsPerAgent = style.isText ? ROWS_PER_AGENT.terminal : ROWS_PER_AGENT.graphical
603 const fit = Math.max(1, Math.floor((maxRows - BAND_CHROME_ROWS) / rowsPerAgent))
604
605 // The header waits for every running task's size, so it doesn't jump as answers arrive.
606 const summary = isEstimating ? (
607 <Text dimColor>Estimating…</Text>
608 ) : isRunning ? (
609 <Text bold>{`All done by ~${clockTime(at + left)}`}</Text>
610 ) : (
611 <Text color={SUCCESS}>All done</Text>
612 )
613
614 // Stacked, the summary sits beside the section's name; otherwise it heads the rows.
615 return section(
616 t,
617 'Subagents',
618 <Box flexDirection="column">
619 {!style.isStacked && summary}
620 {shown.slice(-fit).map(run => agentRow(t, run, at, style))}
621 </Box>,
622 { ...style, card, ...(style.isStacked ? { aside: summary } : {}) },
623 )
624}
625
626function handoffSection(
627 $: EngineInterface,
628 t: ElementTable,
629 ready: ReadyHandoff,
630 style: Style,
631 card?: Card,
632) {
633 const { Box, Text, Button } = t
634 const plain = style.isText || undefined
635
636 return section(
637 t,
638 ready.source === 'auto' ? 'Suggested' : 'Next session',
639 <Box
640 key="handoff"
641 flexDirection={style.isCompact ? 'column' : 'row'}
642 justifyContent="space-between"
643 gap={style.isCompact ? 1 : 2}
644 alignItems={style.isCompact ? 'flex-start' : 'center'}
645 >
646 <Box flexDirection="row" gap={1} flexShrink={1} minWidth={0}>
647 <Text bold wrap="truncate-end">
648 {ready.title}
649 </Text>
650 {ready.reason !== '' && (
651 <Text dimColor wrap="truncate-end">
652 {`· ${ready.reason}`}
653 </Text>
654 )}
655 </Box>
656 <Box flexDirection="row" gap={style.isText ? 3 : 1} flexShrink={0}>
657 <Button
658 key="handoff-start"
659 plain={plain}
660 variant="primary"
661 hotkey="s"
662 label="Start fresh session"
663 onPress={press => startFresh($, ready, press.surface)}
664 />
665 <Button
666 key="handoff-copy"
667 plain={plain}
668 hotkey="c"
669 label="Copy"
670 onPress={press => copyHandoff($, ready, press.surface)}
671 />
672 <Button
673 key="handoff-dismiss"
674 plain={plain}
675 role="dismiss"
676 hotkey="x"
677 label="Dismiss"
678 onPress={() => update($, handoff, () => null)}
679 />
680 </Box>
681 </Box>,
682 { ...style, isCentred: true, card },
683 )
684}
685
686/** The hooks behind the Subagents and Next session sections, and /handoff. */
687export function registerSubagents(on: On, showIn: string) {
688 on('session.measure', ($, e, next) => {
689 local.contextPercent = e.context.percent ?? local.contextPercent
690 return next(e)
691 })
692
693 on('session.end', async ($, e, next) => {
694 if (e.reason === 'clear') {
695 local.turnsSinceCheck = 0
696 local.contextPercent = 0
697 await Promise.all([update($, agents, () => []), update($, handoff, () => null)])
698 }
699
700 return next(e)
701 })
702
703 on('command.run', { command: 'handoff' }, async ($, e) => {
704 if (!(await isActive($, showIn))) {
705 return { text: `Clawd Watch is set to show in the ${showIn} only. Change it under /config.` }
706 }
707
708 const args = (e.args ?? '').trim()
709 const toggle = /^auto\s+(on|off)$/i.exec(args)
710 if (toggle) {
711 const isOn = toggle[1]!.toLowerCase() === 'on'
712 await $.store.set(STORE_AUTO_SUGGEST, isOn)
713
714 return { text: `Handoff suggestions are ${isOn ? 'on' : 'off'}.` }
715 }
716
717 await update($, handoff, () => ({ phase: 'writing' as const, source: 'manual' as const }))
718 $.clock.after(0, () => void writeHandoff($, args))
719
720 return {
721 text: 'Writing a handoff. Clawd Watch will offer to start the fresh session.',
722 }
723 })
724
725 on('agent.spawn', async ($, e, next) => {
726 startTicker($)
727 const [at, expected, isSized] = await Promise.all([
728 $.clock.now(),
729 expectation($, e.subagentType),
730 isActive($, showIn),
731 ])
732 const run: AgentRun = {
733 toolUseId: e.tool_use_id,
734 description: e.description || e.subagentType,
735 type: e.subagentType,
736 startedAt: at,
737 isBackground: e.background,
738 status: 'running',
739 steps: 0,
740 activity: 'Starting',
741 ...expected,
742 isSizing: isSized,
743 }
744 const withoutRun = (list: AgentRun[]) => list.filter(one => one.toolUseId !== run.toolUseId)
745
746 await Promise.all([
747 update($, agents, list =>
748 [...list.filter(one => isShown(one, at)), run].slice(-MAX_AGENT_ROWS),
749 ),
750 update($, now, () => at),
751 ])
752
753 // Sized alongside the subagent, so it starts without waiting on the guess.
754 if (isSized) {
755 const task = `Subagent type: ${e.subagentType}\nTask: ${e.description}\n\nIts instructions:\n${e.prompt.slice(0, SIZE_PROMPT_CHARS)}`
756 void sizeRun($, run.toolUseId, task).catch(() => undefined)
757 }
758
759 let result: Awaited<ReturnType<typeof next>>
760 try {
761 result = await next(e)
762 } catch (error) {
763 await update($, agents, withoutRun)
764 throw error
765 }
766
767 await update($, agents, list =>
768 result.deny !== undefined
769 ? withoutRun(list)
770 : list.map(one =>
771 one.toolUseId === run.toolUseId ? { ...one, agentId: result.agentId } : one,
772 ),
773 )
774
775 return result
776 })
777
778 on('tool.call', async ($, e, next) => {
779 const agentId = e.agentId
780
781 if (agentId !== undefined) {
782 const activity = describeCall(e as unknown as Record<string, unknown>)
783 const [ran] = await Promise.all([
784 next(e),
785 update($, agents, list => {
786 // A subagent's first calls can land before its spawn resolved its id.
787 const owner =
788 list.find(one => one.agentId === agentId) ??
789 [...list].reverse().find(one => one.status === 'running' && one.agentId === undefined)
790
791 return list.map(one =>
792 one === owner ? { ...one, agentId, steps: one.steps + 1, activity } : one,
793 )
794 }),
795 ])
796
797 return ran
798 }
799
800 const ran = await next(e)
801
802 // A foreground Agent call returns once its subagent has finished.
803 if (isAgentTool(String(e.tool))) {
804 await finish(
805 $,
806 one => one.toolUseId === e.tool_use_id && !one.isBackground,
807 ran.deny === undefined && !ran.isError ? 'done' : 'failed',
808 )
809 }
810
811 return ran
812 })
813}
814
815/** The Subagents and Next session sections, as many as have something to show. */
816export async function subagentSections(
817 $: EngineInterface,
818 t: ElementTable,
819 view: View,
820): Promise<JSX.Element[]> {
821 // `now` is read so the ticker's writes redraw while agents run.
822 const [list, pending, at] = await Promise.all([
823 read($, agents),
824 read($, handoff),
825 $.clock.now(),
826 read($, now),
827 ])
828 const shown = list.filter(run => isShown(run, at))
829 const { Text } = t
830 const cols = Math.max(MIN_BAND_COLUMNS, view.columns)
831 const style: Style = {
832 isText: view.isText,
833 isStacked: view.isStacked,
834 isCompact: view.columns < COMPACT_COLUMNS,
835 barCells: clamp(
836 Math.floor((cols - (view.isStacked ? 0 : LABEL_WIDTH)) / COLUMNS_PER_BAR_CELL),
837 BAR_MIN_CELLS,
838 BAR_MAX_CELLS,
839 ),
840 }
841 const card = (name: string) => cardFor($, view, name)
842
843 const sections: JSX.Element[] = []
844 if (shown.length > 0) {
845 sections.push(subagentsSection(t, shown, at, style, view.maxRows, card('Subagents')))
846 }
847 if (pending?.phase === 'writing') {
848 sections.push(
849 section(t, 'Next session', <Text dimColor>Writing the handoff prompt…</Text>, {
850 ...style,
851 card: card('Next session'),
852 }),
853 )
854 }
855 if (pending?.phase === 'ready') {
856 sections.push(handoffSection($, t, pending, style, card('Next session')))
857 }
858
859 return sections
860}
861
862/** What the folded panel says about subagents and the next session. */
863export async function subagentSummary($: EngineInterface): Promise<string[]> {
864 const [list, pending] = await Promise.all([read($, agents), read($, handoff)])
865 const running = list.filter(run => run.status === 'running').length
866
867 return [
868 running > 0 ? `${plural(running, 'subagent')} running` : '',
869 pending?.phase === 'ready' ? 'next session ready' : '',
870 ].filter(part => part !== '')
871}
872
873/** At session start: /handoff, and how full the context is. */
874export async function startSubagents($: EngineInterface) {
875 const [, usage] = await Promise.all([
876 $.command
877 .register({
878 name: 'handoff',
879 description:
880 'Write a starter prompt for a fresh session; a pill above the prompt starts it',
881 argumentHint: '[focus] | auto on | auto off',
882 })
883 .catch(() => undefined),
884 $.session.usage().catch(() => undefined),
885 ])
886 local.contextPercent = usage?.context.percent ?? 0
887}
888
889/** When a turn ends: a subagent's ends its row; the main thread's may ask about a fresh session. */
890export async function subagentsTurnComplete(
891 $: EngineInterface,
892 e: TurnCompleteInput,
893 showIn: string,
894) {
895 const agentId = e.agentId
896
897 if (agentId !== undefined) {
898 await finish($, one => one.agentId === agentId, e.reason === 'answer' ? 'done' : 'failed')
899 return
900 }
901
902 local.turnsSinceCheck += 1
903
904 const pending = await read($, handoff)
905 if (pending?.phase === 'ready' && pending.source === 'auto') {
906 const turnsSince = pending.turnsSince + 1
907 await update($, handoff, () => (turnsSince >= STALE_TURNS ? null : { ...pending, turnsSince }))
908 }
909
910 const isDue =
911 e.reason === 'answer' &&
912 !pending &&
913 local.turnsSinceCheck >= CHECK_AFTER_TURNS &&
914 (local.contextPercent >= CHECK_AT_CONTEXT_PERCENT ||
915 local.turnsSinceCheck >= CHECK_ALWAYS_AFTER_TURNS)
916 if (!isDue) return
917
918 const [autoSuggest, list, active] = await Promise.all([
919 $.store.get(STORE_AUTO_SUGGEST),
920 read($, agents),
921 isActive($, showIn),
922 ])
923 if (autoSuggest !== false && active && !list.some(run => run.status === 'running')) {
924 local.turnsSinceCheck = 0
925 $.clock.after(CHECK_DELAY_MS, () => void autoCheck($))
926 }
927}
928types/index.d.ts 89 lines1export type AgentRun = {
2 toolUseId: string
3 agentId?: string
4 description: string
5 type: string
6 startedAt: number
7 endedAt?: number
8 isBackground: boolean
9 status: 'running' | 'done' | 'failed'
10 steps: number
11 activity: string
12 expectedMs: number
13 expectedSteps: number
14 /** True until Claude has sized the task (or couldn't); the row says "estimating" meanwhile. */
15 isSizing: boolean
16 /** Claude's own guess, before learned corrections, kept to learn from when the run ends. */
17 guessMs?: number
18 guessSteps?: number
19}
20
21export type Handoff =
22 | { phase: 'writing'; source: 'manual' | 'auto' }
23 | {
24 phase: 'ready'
25 source: 'manual' | 'auto'
26 title: string
27 reason: string
28 prompt: string
29 turnsSince: number
30 }
31
32export type QueuedMessage = { id: string; text: string }
33
34/** When each skill was used, in milliseconds since the epoch, newest last. */
35export type Uses = Record<string, number[]>
36
37/** Where a skill comes from: Claude Code itself, written by you, or installed from someone else. */
38export type SkillKind = 'builtin' | 'mine' | 'installed'
39
40export type SkillEntry = {
41 name: string
42 description: string
43 kind: SkillKind
44 /** For an installed skill, where from: the plugin, the repository it was added from, or MCP. */
45 origin?: string
46 /** For a skill that lives in its own files: its folder (a skill) or file (a command). */
47 path?: string
48}
49
50/** Which skills the skills list shows. */
51export type PaneFilter = 'all' | SkillKind | 'hidden'
52
53/** What the pane shows: every section as a card, or all skills. */
54export type PaneView = 'overview' | 'skills'
55
56declare module 'claude-code' {
57 interface PluginState {
58 'clawd-watch': {
59 // Subagents and the next session
60 agents: AgentRun[]
61 now: number
62 handoff: Handoff | null
63 // The queue
64 messages: QueuedMessage[]
65 /** True after the person stopped a turn: nothing is sent until they say so. */
66 isPaused: boolean
67 // Skills
68 pins: string[]
69 uses: Uses
70 skills: SkillEntry[]
71 isInline: boolean
72 /** Skills hidden from the dock and the lists; they still run when typed. */
73 hidden: string[]
74 filter: PaneFilter
75 query: string
76 /** The skill whose removal is waiting on a confirm, if any. */
77 confirming: string | null
78 // The panel
79 /** True while the band above the prompt is folded to one line. */
80 isFolded: boolean
81 /** The names of the desktop app's cards left collapsed. */
82 collapsedCards: string[]
83 paneView: PaneView
84 paneShown: boolean
85 paneUnavailable: boolean
86 }
87 }
88}
89