Saves what the session needs from you and shows it in a side panel with two tabs: Asks (questions, approvals, blockers) and Inbox (news that needs no answer).

Small Claude Code mods (plugins whose hooks run inside the session).
A panel beside your chat that holds everything the session needs from you, so you don't have to scroll long, busy streams to find it.
/snooze 2h) or marked done.Commands: /needs opens the panel, /snooze 2h, /snooze <key> 90m, /snooze wake all.
Needs a Claude Code build with mods (2.1.287 or later).
claude plugin marketplace add purpledotstudio/claude-mods
claude plugin install needs-you@andy-mods
Then restart, or run /reload-plugins.
Update later:
claude plugin marketplace update andy-mods
claude plugin update needs-you@andy-mods
flag, tell, resolve, list.Read plugins/needs-you/hooks/register.tsx before installing, as with any plugin. claude plugin validate plugins/needs-you checks it.
hooks/register.tsx 642 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, Kind, Update, UpdateKind } from '../types'
5import {
6 UPDATE_KINDS,
7 addUpdate,
8 clearUpdates,
9 migrateUpdates,
10 parseTell,
11 removeUpdate,
12 removeUpdateFromFolder,
13 sortUpdates,
14 updatesForFolder,
15 MAX_UPDATES,
16} from './inbox'
17import {
18 KINDS,
19 MAX_ITEMS,
20 addItem,
21 clearFolder,
22 SNOOZES,
23 countByKind,
24 doneNote,
25 forFolder,
26 listText,
27 migrate,
28 parseFlag,
29 parseSnoozeArgs,
30 replyDraft,
31 removeFromFolder,
32 removeItem,
33 snoozeItems,
34 splitSnoozed,
35 sortItems,
36 toggleOpen,
37 untilLabel,
38 whenLabel,
39} from './items'
40
41const FLAG = 'mcp__needs-you__flag'
42const RESOLVE = 'mcp__needs-you__resolve'
43const LIST = 'mcp__needs-you__list'
44const TELL = 'mcp__needs-you__tell'
45const SHOWN = 6
46const MAX_LINE = 140
47
48// How the model must write for the panel. The person skims this between other work.
49const STYLE =
50 'WRITING STYLE for the panel: text is ONE short line (under 100 characters) that says the result or the question, like a headline. details is the longer explanation in plain everyday words: short sentences, one idea each, no jargon (or explain it in the same sentence), no filler, no hedging. Say what happened, why it matters to them, and what to do next. Example text: "Login page is done and tested." Example details: "I built the login page. It signs people in and out. I tested it with 3 bad passwords and it blocked them. Nothing else changed."'
51
52const tooLong = (e: unknown): number => {
53 const raw = (e as { text?: unknown }).text
54 return typeof raw === 'string' && raw.trim().length > MAX_LINE ? raw.trim().length : 0
55}
56const PANE = 'needs-you'
57
58const items = atom({ plugin: 'needs-you', key: 'items' } as const, [])
59const open = atom({ plugin: 'needs-you', key: 'open' } as const, [])
60const collapsed = atom({ plugin: 'needs-you', key: 'collapsed' } as const, false)
61const snoozing = atom({ plugin: 'needs-you', key: 'snoozing' } as const, [])
62const showSnoozed = atom({ plugin: 'needs-you', key: 'showSnoozed' } as const, false)
63
64const inbox = atom({ plugin: 'needs-you', key: 'inbox' } as const, [])
65const tab = atom({ plugin: 'needs-you', key: 'tab' } as const, 'asks')
66
67const UPDATE_LABEL: Record<UpdateKind, { text: string; color: string }> = {
68 done: { text: 'DONE', color: 'green' },
69 new: { text: 'NEW', color: 'cyan' },
70 change: { text: 'CHANGE', color: 'yellow' },
71 info: { text: 'INFO', color: 'gray' },
72}
73
74// True while some item is snoozed, so the minute timer only redraws when it matters.
75let hasSnoozed = false
76
77const LABEL: Record<Kind, { text: string; color: string }> = {
78 blocker: { text: 'BLOCKER', color: 'red' },
79 decision: { text: 'DECIDE', color: 'yellow' },
80 action: { text: 'DO THIS', color: 'green' },
81 review: { text: 'REVIEW', color: 'cyan' },
82 note: { text: 'NOTE', color: 'gray' },
83}
84
85const GUIDE = [
86 'NEEDS-YOU PANEL. The person keeps a panel beside their chat that holds what they must see or do.',
87 `RULE: every time you stop and wait on them, call ${FLAG} before you end the turn. That means a question, an approval or go/no-go, a choice between options, a command only they can run, something to read and answer, or work stuck on them.`,
88 'Do it in a long session and after a compaction just the same. Never rely on your reply alone: they may be away and the panel is how they find it again.',
89 `Kinds: decision (they pick an option or say go/no-go; pass options and recommended), action (they must do it themselves; pass a command or link), review (they must read something and answer in it; pass a link), blocker (work is stuck until they clear it), note (a heads-up, not a task).`,
90 `INBOX. Separate from asks: call ${TELL} for things the person should know but need not answer, so they can skim and press Got it. Kinds: done (a task finished), new (a feature or file added), change (an important twist in the plan or a decision that moved), info (anything else worth knowing). One plain line in text, background in details. Do not use the inbox for questions: those are asks, use ${FLAG}.`,
91 STYLE,
92 'Give every item a short stable key so flagging it again updates it, and say in blocks what waits on it.',
93 'Keep text to one plain line. Put the background in details for someone who has been away a long time: what happened, why you are asking, and what each option leads to.',
94 `Call ${RESOLVE} with the key when they have done it or answered. Do not flag things you can do yourself.`,
95 'A line starting [needs-you] means they pressed Done on an item; treat it as handled.',
96 `If you are unsure what is open (a new turn after a gap, or after a compaction), call ${LIST} first.`,
97].join(' ')
98
99// `$.store` is shared by every session, so each write re-reads it first.
100async function saveStore($: EngineInterface, change: (all: Item[]) => Item[]) {
101 const all = migrate(await $.store.get('items'))
102 await $.store.set('items', change(all))
103}
104
105async function saveInbox($: EngineInterface, change: (all: Update[]) => Update[]) {
106 const all = migrateUpdates(await $.store.get('inbox'))
107 await $.store.set('inbox', change(all))
108}
109
110async function setSnooze($: EngineInterface, folder: string, id: string, until: number | undefined) {
111 await saveStore($, all => snoozeItems(all, folder, id, until))
112 await update($, items, one => snoozeItems(one, folder, id, until))
113}
114
115type RenderEvent = Parameters<EngineInterface['ui']['resolve']>[0]
116
117// True while our docked pane is actually drawn, so the band above the prompt can stand down.
118async function isPaneUp($: EngineInterface): Promise<boolean> {
119 try {
120 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
121 } catch {
122 return false
123 }
124}
125
126// One drawing of the list, used by the pane and by the band above the prompt.
127// `showEmpty` draws a quiet line instead of nothing, for the pane.
128async function asksPanel($: EngineInterface, e: RenderEvent, folder: string, showEmpty: boolean) {
129 const list = await read($, items)
130 const expanded = await read($, open)
131 const isCollapsed = await read($, collapsed)
132 const snoozeRows = await read($, snoozing)
133 const isShowingSnoozed = await read($, showSnoozed)
134 const { Box, Button, Text } = $.ui.resolve(e)
135
136 if (list.length === 0) {
137 return showEmpty ? <Text dimColor>Nothing is waiting on you.</Text> : null
138 }
139
140 const now = await $.clock.now()
141 const { active, snoozed } = splitSnoozed(list, now)
142 hasSnoozed = snoozed.length > 0
143 const sorted = sortItems(active)
144 const shown = sorted.slice(0, SHOWN)
145 const more = sorted.length - shown.length
146
147 // One line of counts, e.g. "BLOCKER 1 | DECIDE 3 | REVIEW 1".
148 const counts = countByKind(active)
149
150 return (
151 <Box flexDirection="column">
152 <Text>
153 <Text bold>Needs you ({active.length}){' '}</Text>
154 {counts.map((row, i) => (
155 <Text key={row.kind}>
156 {i > 0 && <Text dimColor>{' | '}</Text>}
157 <Text color={LABEL[row.kind].color} dimColor={row.kind === 'note'}>
158 {LABEL[row.kind].text} {row.count}
159 </Text>
160 </Text>
161 ))}
162 {snoozed.length > 0 && <Text dimColor>{' '}Snoozed {snoozed.length}</Text>}
163 </Text>
164 <Box>
165 <Button
166 key="collapse"
167 label={isCollapsed ? 'Expand' : 'Collapse'}
168 onPress={() => update($, collapsed, one => !one)}
169 />
170 {snoozed.length > 0 && (
171 <Button
172 key="snoozed"
173 label={isShowingSnoozed ? 'Hide snoozed' : `Show snoozed (${snoozed.length})`}
174 onPress={() => update($, showSnoozed, one => !one)}
175 />
176 )}
177 </Box>
178 {!isCollapsed && isShowingSnoozed && snoozed.map(item => (
179 <Box key={`z-${item.id}`} flexDirection="column">
180 <Text dimColor>
181 {untilLabel(item.snoozedUntil ?? 0, now)}{' '}
182 {LABEL[item.kind].text} {item.text}
183 </Text>
184 <Box>
185 <Button key={`wake-${item.id}`} label="Wake" onPress={() => setSnooze($, folder, item.id, undefined)} />
186 </Box>
187 </Box>
188 ))}
189 {!isCollapsed && shown.map(item => {
190 const isOpen = expanded.includes(item.id)
191 const when = whenLabel(item.at, now)
192
193 return (
194 <Box key={item.id} flexDirection="column">
195 <Text>
196 {when !== '' && <Text bold>{when}{' '}</Text>}
197 <Text color={LABEL[item.kind].color} dimColor={item.kind === 'note'}>
198 {LABEL[item.kind].text}{' '}
199 </Text>
200 <Text dimColor={item.kind === 'note'}>{item.text}</Text>
201 </Text>
202 {isOpen && item.details !== undefined && (
203 <Box flexDirection="column">
204 {item.details.split('\n').map((line, i) => (
205 <Text key={String(i)}>{' '}{line}</Text>
206 ))}
207 </Box>
208 )}
209 {item.command !== undefined && <Text dimColor> $ {item.command}</Text>}
210 {item.options !== undefined && (
211 <Text dimColor>
212 {' '}
213 {item.options
214 .map(o => (o === item.recommended ? `${o} (recommended)` : o))
215 .join(' | ')}
216 </Text>
217 )}
218 {item.blocks !== undefined && <Text dimColor> Blocks: {item.blocks}</Text>}
219 <Box>
220 {item.details !== undefined && (
221 <Button
222 key={`more-${item.id}`}
223 label={isOpen ? 'Show less' : 'Read more'}
224 onPress={() => update($, open, one => toggleOpen(one, item.id))}
225 />
226 )}
227 {item.link !== undefined && (
228 <Button
229 key={`link-${item.id}`}
230 label="Copy link"
231 onPress={ev => void $.ui.copy({ text: item.link ?? '', surface: ev.surface })}
232 />
233 )}
234 {item.command !== undefined && (
235 <Button
236 key={`cmd-${item.id}`}
237 label="Copy command"
238 onPress={ev => void $.ui.copy({ text: item.command ?? '', surface: ev.surface })}
239 />
240 )}
241 {item.kind !== 'note' && (
242 <Button
243 key={`reply-${item.id}`}
244 label="Reply"
245 onPress={async () => {
246 try {
247 const box = await $.prompt.read()
248 await $.prompt.fill({ text: replyDraft(item, box.text), mode: 'append' })
249 } catch {
250 // No prompt box to fill (headless): nothing to do.
251 }
252 }}
253 />
254 )}
255 <Button
256 key={`snooze-${item.id}`}
257 label={snoozeRows.includes(item.id) ? 'Cancel snooze' : 'Snooze'}
258 onPress={() => update($, snoozing, one => toggleOpen(one, item.id))}
259 />
260 <Button
261 key={`done-${item.id}`}
262 label="Done"
263 onPress={async () => {
264 await saveStore($, all => removeFromFolder(all, folder, item.id))
265 await update($, items, one => removeItem(one, item.id))
266 // A quiet note for the model's next turn. Never blocks the button.
267 try {
268 await $.session.append({
269 message: { type: 'user', content: [{ type: 'text', text: doneNote(item) }] },
270 })
271 } catch {
272 // The item is already cleared; the note is a nicety.
273 }
274 }}
275 />
276 </Box>
277 {snoozeRows.includes(item.id) && (
278 <Box>
279 <Text dimColor>Snooze for </Text>
280 {SNOOZES.map(preset => (
281 <Button
282 key={`z-${item.id}-${preset.label}`}
283 label={preset.label}
284 onPress={async () => {
285 await setSnooze($, folder, item.id, (await $.clock.now()) + preset.ms)
286 await update($, snoozing, one => one.filter(id => id !== item.id))
287 }}
288 />
289 ))}
290 </Box>
291 )}
292 </Box>
293 )
294 })}
295 {!isCollapsed && more > 0 && <Text dimColor>+{more} more</Text>}
296 {!isCollapsed && (
297 <Button
298 key="clear"
299 label="Clear all"
300 onPress={async () => {
301 await saveStore($, all => clearFolder(all, folder))
302 await update($, items, () => [])
303 }}
304 />
305 )}
306 </Box>
307 )
308}
309
310// The Inbox tab: things to know, acknowledged with one press.
311async function inboxPanel($: EngineInterface, e: RenderEvent, folder: string) {
312 const list = await read($, inbox)
313 const expanded = await read($, open)
314 const { Box, Button, Text } = $.ui.resolve(e)
315
316 if (list.length === 0) return <Text dimColor>Inbox is empty.</Text>
317
318 const now = await $.clock.now()
319 const shown = sortUpdates(list).slice(0, SHOWN + 2)
320 const more = list.length - shown.length
321
322 return (
323 <Box flexDirection="column">
324 {shown.map(one => {
325 const isOpen = expanded.includes(one.id)
326 const when = whenLabel(one.at, now)
327
328 return (
329 <Box key={one.id} flexDirection="column">
330 <Text>
331 {when !== '' && <Text bold>{when}{' '}</Text>}
332 <Text color={UPDATE_LABEL[one.kind].color}>{UPDATE_LABEL[one.kind].text} </Text>
333 <Text>{one.text}</Text>
334 </Text>
335 {isOpen && one.details !== undefined && (
336 <Box flexDirection="column">
337 {one.details.split('\n').map((line, i) => (
338 <Text key={String(i)}>{' '}{line}</Text>
339 ))}
340 </Box>
341 )}
342 <Box>
343 {one.details !== undefined && (
344 <Button
345 key={`more-${one.id}`}
346 label={isOpen ? 'Show less' : 'Read more'}
347 onPress={() => update($, open, o => toggleOpen(o, one.id))}
348 />
349 )}
350 {one.link !== undefined && (
351 <Button
352 key={`link-${one.id}`}
353 label="Copy link"
354 onPress={ev => void $.ui.copy({ text: one.link ?? '', surface: ev.surface })}
355 />
356 )}
357 <Button
358 key={`ack-${one.id}`}
359 label="Got it"
360 onPress={async () => {
361 await saveInbox($, all => removeUpdateFromFolder(all, folder, one.id))
362 await update($, inbox, o => removeUpdate(o, one.id))
363 }}
364 />
365 </Box>
366 </Box>
367 )
368 })}
369 {more > 0 && <Text dimColor>+{more} more</Text>}
370 <Button
371 key="ack-all"
372 label="Got it all"
373 onPress={async () => {
374 await saveInbox($, all => clearUpdates(all, folder))
375 await update($, inbox, () => [])
376 }}
377 />
378 </Box>
379 )
380}
381
382// Both tabs, used by the pane and by the band above the prompt.
383async function panel($: EngineInterface, e: RenderEvent, folder: string, showEmpty: boolean) {
384 const asks = await read($, items)
385 const updates = await read($, inbox)
386 const current = await read($, tab)
387 if (!showEmpty && asks.length === 0 && updates.length === 0) return null
388
389 const { Box, Button, Text } = $.ui.resolve(e)
390 const waiting = splitSnoozed(asks, await $.clock.now()).active.length
391 const onInbox = current === 'inbox'
392
393 // A tab: the chosen one is filled in, the other is dim. Padding gives it a body.
394 const tabButton = (id: 'asks' | 'inbox', label: string) => (
395 <Button key={`tab-${id}`} plain onPress={() => update($, tab, () => id)}>
396 <Text bold={current === id} inverse={current === id} dimColor={current !== id}>
397 {` ${label} `}
398 </Text>
399 </Button>
400 )
401
402 return (
403 <Box flexDirection="column">
404 <Box>
405 {tabButton('asks', `Asks ${waiting}`)}
406 <Text>{' '}</Text>
407 {tabButton('inbox', `Inbox ${updates.length}`)}
408 </Box>
409 <Box flexDirection="column" borderStyle="single" borderDimColor paddingX={1}>
410 {onInbox ? await inboxPanel($, e, folder) : await asksPanel($, e, folder, true)}
411 </Box>
412 </Box>
413 )
414}
415
416export const register: Register = on => {
417 let cwd = ''
418 let tick: { cancel: () => void } | undefined
419
420 on('session.start', async ($, e, next) => {
421 cwd = e.cwd
422 const all = migrate(await $.store.get('items'))
423 await update($, items, () => forFolder(all, cwd))
424 const updates = migrateUpdates(await $.store.get('inbox'))
425 await update($, inbox, () => updatesForFolder(updates, cwd))
426
427 await $.tool.register({
428 name: 'flag',
429 description:
430 'Save something the person needs to see or do, so it shows in their panel until it is resolved or dismissed. Call this every time you end a turn waiting on them. ' +
431 'Flagging again with the same key updates the item.',
432 inputSchema: {
433 type: 'object',
434 properties: {
435 kind: {
436 type: 'string',
437 enum: [...KINDS],
438 description:
439 'decision (pick an option or go/no-go), action (they do it themselves), review (read and answer in a doc), blocker (stuck until cleared), note (heads-up).',
440 },
441 text: { type: 'string', description: 'A headline: one short line, under 100 characters, plain words. The longer explanation goes in details.' },
442 key: { type: 'string', description: 'Short stable id, e.g. "merge-pr-60". Same key updates the item.' },
443 details: {
444 type: 'string',
445 description:
446 'The longer explanation, behind Read more. Plain everyday words, short sentences, one idea each, no jargon, no filler: what happened, why it matters to them, what to do next. Up to about 1500 characters.',
447 },
448 options: { type: 'array', items: { type: 'string' }, description: 'decision only: up to 4 short options.' },
449 recommended: { type: 'string', description: 'decision only: the option you recommend, exactly as in options.' },
450 command: { type: 'string', description: 'action only: the exact command to run.' },
451 link: { type: 'string', description: 'A PR, doc or page URL (http or https).' },
452 blocks: { type: 'string', description: 'What is waiting on this, or "nothing, can wait".' },
453 },
454 required: ['kind', 'text'],
455 },
456 isDeferred: false,
457 })
458
459 await $.tool.register({
460 name: 'tell',
461 description:
462 'Put something in the person\'s Inbox: news they should know but need not answer (a task finished, a feature added, a change of plan). They only acknowledge it. Not for questions: use flag for those.',
463 inputSchema: {
464 type: 'object',
465 properties: {
466 kind: {
467 type: 'string',
468 enum: [...UPDATE_KINDS],
469 description: 'done (task finished), new (feature or file added), change (twist in the plan), info (other).',
470 },
471 text: { type: 'string', description: 'A headline: one short line, under 100 characters, plain words. The longer explanation goes in details.' },
472 key: { type: 'string', description: 'Optional short stable id; the same key updates the entry.' },
473 details: { type: 'string', description: 'The longer explanation, behind Read more. Plain everyday words, short sentences, one idea each, no jargon, no filler: what happened, why it matters, what to do next. Up to about 1500 characters.' },
474 link: { type: 'string', description: 'A PR, doc or page URL (http or https).' },
475 },
476 required: ['kind', 'text'],
477 },
478 isDeferred: false,
479 })
480
481 await $.tool.register({
482 name: 'resolve',
483 description: 'Clear an item from the person\'s panel once they have done it or answered. Pass the key you flagged it with.',
484 inputSchema: {
485 type: 'object',
486 properties: { key: { type: 'string', description: 'The key the item was flagged with.' } },
487 required: ['key'],
488 },
489 isDeferred: false,
490 })
491
492 await $.tool.register({
493 name: 'list',
494 description: 'List what is still open in the person\'s panel for this folder, with each key. Items they dismissed are gone.',
495 inputSchema: { type: 'object', properties: {} },
496 isDeferred: false,
497 })
498
499 await $.command.register({ name: 'needs', description: 'Open the Needs you panel beside the chat' })
500 await $.command.register({
501 name: 'snooze',
502 description: 'Hide items for a while: /snooze 2h, /snooze <key> 90m, /snooze wake all',
503 })
504
505 // Snoozed items come back on their own: redraw once a minute while any are snoozed.
506 tick?.cancel()
507 tick = $.clock.every(60_000, () => {
508 if (hasSnoozed) $.ui.invalidate('ui.render')
509 })
510 // Unasked, so it only seats in a wide enough terminal; otherwise the band above the prompt shows.
511 void $.ui.open({ id: PANE, title: 'Needs you' })
512
513 return next(e)
514 })
515
516 on('prompt.compose', async ($, e, next) => {
517 const out = await next(e)
518
519 return { sections: [...out.sections, { id: 'needs-you:guide', text: GUIDE, scope: 'session' }] }
520 })
521
522 // After a long run the summary can drop the habit. Tell the summarizer to keep it, with what is open.
523 on('session.compact', async ($, e, next) => {
524 let open = ''
525 try {
526 const list = await read($, items)
527 open = list.length === 0 ? '' : ` Open items now:\n${listText(list, await $.clock.now())}`
528 } catch {
529 // The list is a nicety; the rule alone is still worth keeping.
530 }
531
532 const keep =
533 `Keep this rule: call ${TELL} for news the person need not answer (done, new, change, info). Keep this rule in the summary: whenever you stop and wait on the person (a question, approval, choice, command or review), call ${FLAG} before ending the turn; call ${RESOLVE} when it is answered; call ${LIST} if unsure what is open.${open}`
534
535 return next({ ...e, instructions: e.instructions === undefined || e.instructions === '' ? keep : `${e.instructions}\n\n${keep}` })
536 })
537
538 on('tool.call', { tool: FLAG }, async ($, e) => {
539 const longBy = tooLong(e)
540 if (longBy > 0) {
541 return { result: `Not saved: text is ${longBy} characters. Keep it to one short line (under 100) and put the explanation in details, in plain words.` }
542 }
543 const flag = parseFlag(e as unknown as Record<string, unknown>)
544 if (flag === null) {
545 return { result: `Not saved. kind must be one of ${KINDS.join(', ')} and text must not be empty.` }
546 }
547
548 const wasEmpty = (await read($, items)).length === 0
549 const { key, ...rest } = flag
550 const item: Item = { ...rest, id: key ?? e.tool_use_id, cwd, at: await $.clock.now() }
551 await saveStore($, all => addItem(all, item))
552 await update($, items, list => addItem(list, item).slice(-MAX_ITEMS))
553 if (wasEmpty) void $.ui.open({ id: PANE, title: 'Needs you' })
554
555 return { result: 'Saved. The person sees it in their panel.' }
556 }).catch(() => ({ result: 'Not saved: the needs-you panel failed. Tell the person in your reply instead.' }))
557
558 on('tool.call', { tool: /^mcp__needs-you__tell$/ }, async ($, e) => {
559 const longBy = tooLong(e)
560 if (longBy > 0) {
561 return { result: `Not saved: text is ${longBy} characters. Keep it to one short line (under 100) and put the explanation in details, in plain words.` }
562 }
563 const tell = parseTell(e as unknown as Record<string, unknown>)
564 if (tell === null) {
565 return { result: `Not saved. kind must be one of ${UPDATE_KINDS.join(', ')} and text must not be empty.` }
566 }
567
568 const wasEmpty = (await read($, inbox)).length === 0 && (await read($, items)).length === 0
569 const { key, ...rest } = tell
570 const one: Update = { ...rest, id: key ?? e.tool_use_id, cwd, at: await $.clock.now() }
571 await saveInbox($, all => addUpdate(all, one))
572 await update($, inbox, list => addUpdate(list, one).slice(-MAX_UPDATES))
573 if (wasEmpty) void $.ui.open({ id: PANE, title: 'Needs you' })
574
575 return { result: 'Saved to the person\'s Inbox.' }
576 }).catch(() => ({ result: 'Not saved: the inbox failed. Say it in your reply instead.' }))
577
578 on('tool.call', { tool: /^mcp__needs-you__resolve$/ }, async ($, e) => {
579 const raw = (e as unknown as { key?: unknown }).key
580 const key = typeof raw === 'string' ? raw.trim() : ''
581 if (key === '') return { result: 'Not cleared: pass the key the item was flagged with.' }
582
583 const list = await read($, items)
584 if (!list.some(one => one.id === key)) return { result: `Nothing to clear: no item with key "${key}".` }
585
586 await saveStore($, all => removeFromFolder(all, cwd, key))
587 await update($, items, one => removeItem(one, key))
588
589 return { result: 'Cleared.' }
590 }).catch(() => ({ result: 'Not cleared: the needs-you panel failed.' }))
591
592 on('tool.call', { tool: /^mcp__needs-you__list$/ }, async $ => {
593 return { result: listText(await read($, items), await $.clock.now()) }
594 }).catch(() => ({ result: 'Could not read the panel.' }))
595
596 // The docked pane: a panel beside the chat in a wide, fullscreen terminal.
597 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
598 return (await panel($, e, cwd, true)) ?? next(e)
599 })
600
601 // /needs opens the pane on request, at any width, if it was closed.
602 on('command.run', { command: 'needs' }, async $ => {
603 const opened = await $.ui.open({ id: PANE, title: 'Needs you' })
604
605 return { text: opened.isPlaced ? 'Opened the Needs you panel.' : 'No room for the panel here; the list shows above the prompt instead.' }
606 })
607
608 // /snooze 2h | /snooze <key> 90m | /snooze wake <key|all>
609 on('command.run', { command: 'snooze' }, async ($, e) => {
610 const cmd = parseSnoozeArgs(e.args)
611 if (cmd === null) {
612 return { text: 'Try /snooze 2h (everything), /snooze <key> 90m (one item), or /snooze wake all. Units: m, h, d.' }
613 }
614
615 const list = await read($, items)
616 if (cmd.target !== 'all' && !list.some(one => one.id === cmd.target)) {
617 return { text: `No item with key "${cmd.target}".` }
618 }
619
620 const now = await $.clock.now()
621 const count = cmd.target === 'all' ? list.length : 1
622 if (count === 0) return { text: 'Nothing to snooze.' }
623
624 if (cmd.action === 'wake') {
625 await setSnooze($, cwd, cmd.target, undefined)
626 return { text: `Woke ${count} item${count === 1 ? '' : 's'}.` }
627 }
628
629 await setSnooze($, cwd, cmd.target, now + cmd.ms)
630 return { text: `Snoozed ${count} item${count === 1 ? '' : 's'} ${untilLabel(now + cmd.ms, now)}.` }
631 })
632
633 // The same list above the prompt, only while the pane is not drawn (narrow window).
634 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
635 if (e.props.hasSurvey || (await isPaneUp($))) {
636 return next(e)
637 }
638
639 return (await panel($, e, cwd, false)) ?? next(e)
640 })
641}
642hooks/inbox.ts 55 lines1import type { Update, UpdateKind } from '../types'
2
3export const UPDATE_KINDS: readonly UpdateKind[] = ['done', 'new', 'change', 'info']
4export const MAX_UPDATES = 200
5const MAX_TEXT = 300
6const MAX_DETAILS = 1500
7
8const clean = (value: unknown, max = MAX_TEXT): string =>
9 typeof value === 'string' ? value.trim().slice(0, max) : ''
10
11export type Tell = { kind: UpdateKind; text: string; details?: string; link?: string; key?: string }
12
13// Reads the model's tool input. Returns null when it is not a valid update.
14export const parseTell = (input: Record<string, unknown>): Tell | null => {
15 const kind = UPDATE_KINDS.find(k => k === input.kind)
16 const text = clean(input.text)
17 if (kind === undefined || text === '') return null
18
19 const tell: Tell = { kind, text }
20 const key = clean(input.key, 80)
21 if (key !== '') tell.key = key
22 const details = clean(input.details, MAX_DETAILS)
23 if (details !== '') tell.details = details
24 const link = clean(input.link, 500)
25 if (/^https?:\/\/\S+$/.test(link)) tell.link = link
26
27 return tell
28}
29
30// Same folder + same key (or same kind and text) updates in place.
31export const addUpdate = (all: readonly Update[], one: Update): Update[] =>
32 [...all.filter(u => !(u.cwd === one.cwd && (u.id === one.id || (u.kind === one.kind && u.text === one.text)))), one].slice(
33 -MAX_UPDATES,
34 )
35
36export const removeUpdate = (all: readonly Update[], id: string): Update[] => all.filter(u => u.id !== id)
37
38export const removeUpdateFromFolder = (all: readonly Update[], cwd: string, id: string): Update[] =>
39 all.filter(u => !(u.cwd === cwd && u.id === id))
40
41export const clearUpdates = (all: readonly Update[], cwd: string): Update[] => all.filter(u => u.cwd !== cwd)
42
43export const updatesForFolder = (all: readonly Update[], cwd: string): Update[] => all.filter(u => u.cwd === cwd)
44
45// Newest first.
46export const sortUpdates = (list: readonly Update[]): Update[] => [...list].sort((a, b) => b.at - a.at)
47
48export const migrateUpdates = (raw: unknown): Update[] =>
49 Array.isArray(raw)
50 ? raw.filter(
51 (u): u is Update =>
52 typeof u === 'object' && u !== null && UPDATE_KINDS.some(k => k === (u as { kind?: unknown }).kind),
53 )
54 : []
55hooks/items.ts 241 lines1import type { Item, Kind } from '../types'
2
3export const KINDS: readonly Kind[] = ['decision', 'action', 'review', 'blocker', 'note']
4export const MAX_ITEMS = 200
5const MAX_TEXT = 300
6const MAX_DETAILS = 1500
7const MAX_OPTION = 80
8const MAX_OPTIONS = 4
9
10// Order of kinds in the summary line: things that stop work first, quiet notes last.
11const RANK: Record<Kind, number> = { blocker: 0, decision: 1, action: 2, review: 3, note: 4 }
12
13// Items saved by version 0.1 used other kind names.
14const LEGACY: Record<string, Kind> = { run: 'action', suggestion: 'decision', input: 'decision' }
15
16export type Flag = {
17 kind: Kind
18 text: string
19 details?: string
20 key?: string
21 command?: string
22 link?: string
23 blocks?: string
24 options?: string[]
25 recommended?: string
26}
27
28const clean = (value: unknown, max = MAX_TEXT): string =>
29 typeof value === 'string' ? value.trim().slice(0, max) : ''
30
31export const normalizeKind = (value: unknown): Kind | undefined =>
32 KINDS.find(k => k === value) ?? (typeof value === 'string' ? LEGACY[value] : undefined)
33
34// Reads the model's tool input. Returns null when it is not a valid flag.
35export const parseFlag = (input: Record<string, unknown>): Flag | null => {
36 const kind = KINDS.find(k => k === input.kind)
37 const text = clean(input.text)
38 if (kind === undefined || text === '') return null
39
40 const flag: Flag = { kind, text }
41
42 const key = clean(input.key, 80)
43 if (key !== '') flag.key = key
44
45 // Longer background for someone who has been away. Shown behind "Read more".
46 const details = clean(input.details, MAX_DETAILS)
47 if (details !== '') flag.details = details
48
49 const command = clean(input.command)
50 if (kind === 'action' && command !== '') flag.command = command
51
52 // A link is only ever shown as text and copied, but still must look like one.
53 const link = clean(input.link, 500)
54 if (/^https?:\/\/\S+$/.test(link)) flag.link = link
55
56 const blocks = clean(input.blocks, 120)
57 if (blocks !== '') flag.blocks = blocks
58
59 if (kind === 'decision' && Array.isArray(input.options)) {
60 const options = input.options
61 .map(o => clean(o, MAX_OPTION))
62 .filter(o => o !== '')
63 .slice(0, MAX_OPTIONS)
64 if (options.length > 0) {
65 flag.options = options
66 const recommended = clean(input.recommended, MAX_OPTION)
67 if (options.includes(recommended)) flag.recommended = recommended
68 }
69 }
70
71 return flag
72}
73
74// Same folder + same key (or same kind and text when no key) updates in place.
75export const addItem = (all: readonly Item[], item: Item): Item[] => {
76 const same = (one: Item): boolean =>
77 one.cwd === item.cwd && (one.id === item.id || (one.kind === item.kind && one.text === item.text))
78
79 const rest = all.filter(one => !same(one))
80 return [...rest, item].slice(-MAX_ITEMS)
81}
82
83export const removeItem = (all: readonly Item[], id: string): Item[] =>
84 all.filter(one => one.id !== id)
85
86export const removeFromFolder = (all: readonly Item[], cwd: string, id: string): Item[] =>
87 all.filter(one => !(one.cwd === cwd && one.id === id))
88
89export const clearFolder = (all: readonly Item[], cwd: string): Item[] =>
90 all.filter(one => one.cwd !== cwd)
91
92export const forFolder = (all: readonly Item[], cwd: string): Item[] =>
93 all.filter(one => one.cwd === cwd)
94
95// Newest first, so what just happened is at the top.
96export const sortItems = (list: readonly Item[]): Item[] =>
97 [...list].sort((a, b) => b.at - a.at)
98
99// How many items of each kind, in panel order, skipping kinds with none.
100export const countByKind = (list: readonly Item[]): { kind: Kind; count: number }[] =>
101 [...KINDS]
102 .sort((a, b) => RANK[a] - RANK[b])
103 .map(kind => ({ kind, count: list.filter(one => one.kind === kind).length }))
104 .filter(row => row.count > 0)
105
106// Snooze presets for the buttons. "Tomorrow" is simply 24 hours from now.
107export const SNOOZES: readonly { label: string; ms: number }[] = [
108 { label: '1h', ms: 3_600_000 },
109 { label: '4h', ms: 4 * 3_600_000 },
110 { label: '8h', ms: 8 * 3_600_000 },
111 { label: 'Tomorrow', ms: 24 * 3_600_000 },
112]
113const MAX_SNOOZE_MS = 30 * 24 * 3_600_000
114
115export const isSnoozed = (item: Pick<Item, 'snoozedUntil'>, now: number): boolean =>
116 item.snoozedUntil !== undefined && item.snoozedUntil > now
117
118export const splitSnoozed = (list: readonly Item[], now: number): { active: Item[]; snoozed: Item[] } => ({
119 active: list.filter(one => !isSnoozed(one, now)),
120 snoozed: list.filter(one => isSnoozed(one, now)),
121})
122
123// Snoozes (or, with `until` undefined, wakes) one item in a folder, or every
124// item there when `id` is 'all'.
125export const snoozeItems = (all: readonly Item[], cwd: string, id: string, until: number | undefined): Item[] =>
126 all.map(one => {
127 if (one.cwd !== cwd || (id !== 'all' && one.id !== id)) return one
128 const { snoozedUntil: _gone, ...rest } = one
129 return until === undefined ? rest : { ...rest, snoozedUntil: until }
130 })
131
132// "90m", "2h", "1.5h", "1d"; a bare number is minutes. Null when not a duration.
133export const parseDuration = (text: string): number | null => {
134 const m = /^(\d+(?:\.\d+)?)\s*(m|min|mins|h|hr|hrs|d|day|days)?$/i.exec(text.trim())
135 if (m === null) return null
136
137 const unit = (m[2] ?? 'm').toLowerCase()
138 const per = unit.startsWith('d') ? 86_400_000 : unit.startsWith('h') ? 3_600_000 : 60_000
139 const ms = Math.round(Number(m[1]) * per)
140
141 return ms >= 60_000 && ms <= MAX_SNOOZE_MS ? ms : null
142}
143
144export type SnoozeCommand =
145 | { action: 'snooze'; target: string; ms: number }
146 | { action: 'wake'; target: string }
147
148// `/snooze 2h`, `/snooze merge-pr-60 90m`, `/snooze wake all`. Null when unclear.
149export const parseSnoozeArgs = (args: string): SnoozeCommand | null => {
150 const words = args.trim().split(/\s+/).filter(w => w !== '')
151
152 if (words[0]?.toLowerCase() === 'wake' && words.length === 2) {
153 return { action: 'wake', target: words[1] ?? 'all' }
154 }
155 if (words.length === 1) {
156 const ms = parseDuration(words[0] ?? '')
157 return ms === null ? null : { action: 'snooze', target: 'all', ms }
158 }
159 if (words.length === 2) {
160 const ms = parseDuration(words[1] ?? '')
161 return ms === null ? null : { action: 'snooze', target: words[0] ?? 'all', ms }
162 }
163
164 return null
165}
166
167// "until 18:05" today, "until Wed 09:00" another day.
168export const untilLabel = (until: number, now: number): string => {
169 const d = new Date(until)
170 if (Number.isNaN(d.getTime())) return ''
171 const pad = (n: number) => String(n).padStart(2, '0')
172 const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
173 const same = d.toDateString() === new Date(now).toDateString()
174
175 return `until ${same ? '' : `${days[d.getDay()]} `}${pad(d.getHours())}:${pad(d.getMinutes())}`
176}
177
178// Brings items saved by older versions up to the current shape.
179export const migrate = (raw: unknown): Item[] => {
180 if (!Array.isArray(raw)) return []
181
182 return raw.flatMap((one): Item[] => {
183 const kind = normalizeKind((one as { kind?: unknown } | null)?.kind)
184 if (kind === undefined) return []
185 return [{ ...(one as Item), kind }]
186 })
187}
188
189// The one line added to the session when the person presses Done. Keeps the
190// model's own words out of the note beyond a short, capped copy.
191export const doneNote = (item: Pick<Item, 'id' | 'kind' | 'text'>): string =>
192 `[needs-you] The person marked this item done: ${item.kind} "${item.text.slice(0, 160)}" (key ${item.id}). ` +
193 'No reply needed. Do not list it as waiting any more.'
194
195// What the list tool returns to the model.
196export const listText = (list: readonly Item[], now = 0): string => {
197 if (list.length === 0) return 'The panel is empty: nothing is waiting on the person.'
198
199 return sortItems(list)
200 .map(
201 one =>
202 `- [${one.kind}] key ${one.id}: ${one.text}${one.blocks === undefined ? '' : ` (blocks: ${one.blocks})`}` +
203 (isSnoozed(one, now) ? ` (snoozed ${untilLabel(one.snoozedUntil ?? 0, now)})` : ''),
204 )
205 .join('\n')
206}
207
208// "14:05 (2h ago)" for today, "Mon 14:05 (2d ago)" within a week, "3 Oct 14:05 (9d ago)"
209// after that. Empty when the time cannot be formatted.
210export const whenLabel = (at: number, now: number): string => {
211 const d = new Date(at)
212 if (Number.isNaN(d.getTime())) return ''
213
214 const pad = (n: number) => String(n).padStart(2, '0')
215 const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`
216 const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
217 const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
218 const today = new Date(now)
219 const sameDay = d.toDateString() === today.toDateString()
220 const ageMs = Math.max(0, now - at)
221 const mins = Math.floor(ageMs / 60000)
222 const hours = Math.floor(mins / 60)
223 const daysAgo = Math.floor(hours / 24)
224
225 const age = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : hours < 24 ? `${hours}h ago` : `${daysAgo}d ago`
226 const day = sameDay ? '' : daysAgo < 7 ? `${days[d.getDay()]} ` : `${d.getDate()} ${months[d.getMonth()]} `
227
228 return `${day}${clock} (${age})`
229}
230
231export const toggleOpen = (open: readonly string[], id: string): string[] =>
232 open.includes(id) ? open.filter(one => one !== id) : [...open, id]
233
234// Text the Reply button puts in the prompt box. Keeps whatever was already
235// typed and leaves the cursor line free for the answer.
236export const replyDraft = (item: Pick<Item, 'id' | 'text'>, draft: string): string => {
237 const lead = draft === '' ? '' : draft.endsWith('\n') ? '\n' : '\n\n'
238
239 return `${lead}Re: ${item.text.slice(0, 160)} [${item.id}]\n`
240}
241types/index.d.ts 37 lines1export type Kind = 'decision' | 'action' | 'review' | 'blocker' | 'note'
2
3export type Item = {
4 id: string
5 kind: Kind
6 text: string
7 details?: string
8 command?: string
9 link?: string
10 blocks?: string
11 options?: string[]
12 recommended?: string
13 cwd: string
14 at: number
15 // Hidden from the panel until this time (ms since 1970).
16 snoozedUntil?: number
17}
18
19export type UpdateKind = 'done' | 'new' | 'change' | 'info'
20
21// Something the person should know. They only acknowledge it.
22export type Update = {
23 id: string
24 kind: UpdateKind
25 text: string
26 details?: string
27 link?: string
28 cwd: string
29 at: number
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'needs-you': { items: Item[]; open: string[]; collapsed: boolean; snoozing: string[]; showSnoozed: boolean; inbox: Update[]; tab: 'asks' | 'inbox' }
35 }
36}
37