SLOPSHOPPER

needs-you

A side pane that collects everything Claude needs from you: decisions, things to do, things to know. Answer in place; the transcript stays quiet.

newpanespinnerrowsguardcommand
v0.1.0MITupdated 2026-10-24brandon-stride/needs-you
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · needs-you
│ ┃ Needs you ✕ › fix the failing auth test and add an audit log call │ ┃ Needs you nothing waiting │ ┃ ⏺ Read(src/auth.ts) │ ┃ Claude pins what it needs from you here. ⎿ Read 6 lines │ ┃ Try: /needs-you add something ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /needs-you │ ┃ ⎿ needs-you: Needs-you pane open: 0 items waiting. Focus mode is o │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Needs you
Needs you nothing waiting view close Claude pins what it needs from you here. Try: /needs-you add something
README

needs-you

A Claude Code mod. Everything Claude needs from you lands in one side pane: decisions to make, things to do outside the chat, things you should know. You answer there. The transcript stays quiet.

Why

Working with an agent means a lot of scrolling back to find the one line that actually needed you: which option?, did you paste the key?, heads up, the old endpoint goes away. Those questions get buried under tool output and narration.

needs-you gives Claude a pin tool and a standing instruction to use it the moment something needs you, not at the end of a long reply. Each pin becomes a card in a docked pane. You answer on the card and the answer reaches Claude as a normal message. Nothing else changes about how you work.

Install

At the prompt of a terminal session:

/plugin install needs-you --marketplace brandon-stride/needs-you

Answer y to add the marketplace, then pick a scope. From a shell: claude plugin install needs-you --marketplace brandon-stride/needs-you. In the desktop app: + next to the prompt, Plugins, add the marketplace brandon-stride/needs-you, then add the plugin.

The pane docks beside the transcript in fullscreen terminals of 110 columns or more, and sits above the prompt in narrower ones. Pictures (the mascot) need a terminal with the kitty graphics protocol: Ghostty, kitty, WezTerm, iTerm2 3.5. Elsewhere it falls back to half-block pixels.

What you see

Each card has a colour for its kind, the question in plain words, the reason, and the ways to answer:

KindColourAnswer with
decideamberoption chips, the recommendation starred, or ✓ go with it
doclay✓ done once you have done it
knowsky✓ got it

Every card also takes a note (context, a correction) and has 💬 chat, which brings the item into the conversation.

Your answer goes to Claude quietly while a turn runs, or as a prompt when the session is idle. An answered card folds to one line, "sending", then "Claude has it", and leaves once Claude resolves it. Helper agents can pin too; the card says who asked.

Focus mode (on by default) hides tool rows in the transcript and folds Claude's working narration once a turn ends, so what stays on screen is the answer and the pane. Press ctrl+o to see everything, or turn it off below.

The mascot is a pixel Clawd who lives in the minimized pane: a random hat, something in hand, a dance while items wait, a reaction when you press something, and a nap when nothing does.

Commands

CommandDoes
/needs-youopen the pane
/needs-you add <text>pin a reminder of your own
/needs-you focus on / offfocus mode
/needs-you catch on / offthe reply catcher, which pins questions Claude asked in prose but forgot to pin
/needs-you cleardrop every item

─ minimizes the pane, ✕ or Escape closes it until the next pin. While items wait and the pane is off screen, a line under the prompt says so.

How it works

  • hooks/register.tsx holds every hook: the tools Claude calls (pin, resolve, update, list), the system-prompt section that tells it when to pin, turn tracking, the pane, focus mode and the command.
  • hooks/items.ts is pure logic: parsing pins, building the messages, the seen/archive rules, the reply catcher.
  • hooks/clawd.ts draws the mascot on a 30×28 pixel grid and turns it into RGBA for a terminal image or half-block cells for a raster.
  • types/index.d.ts is the state contract the engine checks the module against.

Items persist per session in the plugin store; settings persist across sessions.

Development

claude --plugin-dir .      # run it from this folder; saves hot-reload
npm run validate           # claude plugin validate .
npm test                   # claude plugin test .
npm run typecheck          # needs .claude-plugin/types/, which the engine writes on first load
npm run media              # re-render media/clawd.png from the drawing code

License

MIT

Source 4 files
hooks/register.tsx 1116 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { RASTER_COLS, RASTER_ROWS, draw, frameAt, frameKey, toRaster, toRgba } from './clawd'
5import type { Reaction } from './clawd'
6
7import type { Item, Kind, Live, Note, PaneSettings } from '../types'
8import {
9  CATCH_SYSTEM,
10  DEFAULT_SETTINGS,
11  TOOL_LIST,
12  TOOL_PIN,
13  TOOL_RESOLVE,
14  TOOL_UPDATE,
15  WAKE_TEXT,
16  archiveSeen,
17  avatarLine,
18  catchPrompt,
19  caughtNote,
20  clip,
21  doneItems,
22  findByN,
23  firstLine,
24  fyiFor,
25  hasCue,
26  isQuiet,
27  listReply,
28  markSeen,
29  messageFor,
30  newItem,
31  normalize,
32  oneLine,
33  openItems,
34  parseCaught,
35  parseCommand,
36  parsePin,
37  pendingKey,
38  personMessageFor,
39  pinReply,
40  wantedRows,
41} from './items'
42import type { Answer, Pin } from './items'
43
44const PANE = 'needs-you'
45
46// Obsidian: the pane sits a hair above the terminal's near-black (#08090a), each
47// item a card a step above that, with one colour per kind on its edge.
48const INK = {
49  pane: '#0d0f12',
50  card: '#14171c',
51  cardOpen: '#1a1e25',
52  mute: '#5c636e',
53  chip: '#262b33',
54  chipText: '#d7dbe2',
55  decide: '#E6B84A',
56  do: '#D97757',
57  know: '#6FA8DC',
58} as const
59// Ask for a wide dock: the engine caps it at 45% of the terminal.
60const COLUMNS = 90
61const FRESH_MS = 12_000
62const TICK_MS = 250
63const REACT_MS = 2_500
64// The picture's box in the pane: 10 columns by 5 rows is about square.
65const PIC_COLS = 10
66const PIC_ROWS = 5
67const PIC_SCALE = 3
68// A second "chat about this" on the same item within this long is ignored.
69const CHAT_MS = 15_000
70// Option labels on chips are cut to this many columns.
71const OPTION_COLS = 30
72// The store keeps other sessions' lists this long, and this many of them.
73const STORE_SHAPE = 'items-2'
74const KEEP_MS = 14 * 24 * 60 * 60 * 1000
75const KEEP_SESSIONS = 30
76// The folded-narration ids kept across turns.
77const INTERIM_CAP = 500
78
79type $T = EngineInterface
80
81// Bump a tag when its value's fields change: old values are then declined.
82const items = atom({ plugin: 'needs-you', key: 'items' } as const, [] as Item[], { shape: 'items-1' })
83const settings = atom({ plugin: 'needs-you', key: 'settings' } as const, { ...DEFAULT_SETTINGS } as PaneSettings, { shape: 'settings-1' })
84const live = atom({ plugin: 'needs-you', key: 'live' } as const, {
85  isTurnRunning: false,
86  turnId: '',
87  pinsThisTurn: 0,
88  lastPinAt: 0,
89  tick: 0,
90} as Live)
91// Assistant blocks of past turns that came before each turn's last tool call:
92// the working narration, folded to one line once its turn is over.
93const interim = atom({ plugin: 'needs-you', key: 'interim' } as const, [] as string[])
94
95// Per-load bookkeeping; a reload starts it over, the state above stays.
96const local = {
97  sessionId: '',
98  paneIsPlaced: false,
99  drafts: {} as Record<string, string>,
100  // Assistant blocks first drawn during the running main turn, in order, and
101  // every block drawn before it began (a re-draw of an old one is not new).
102  assistantIds: [] as string[],
103  knownIds: new Set<string>(),
104  cutIndex: 0,
105  isTurnRunning: false,
106  // Items whose answer is being handled right now (a second press is ignored),
107  // and those whose row is mid-append, so no request counts as carrying it yet.
108  inFlight: new Set<string>(),
109  holding: new Set<string>(),
110  chatAt: {} as Record<string, number>,
111  // The pending answers the last wake-up prompt was for.
112  wokeFor: '',
113  storeWarned: false,
114  // The mascot: pictures while the terminal draws them, half-block cells once
115  // a blit says it cannot; the last frame blitted, so a beat with no change
116  // sends nothing; the reaction to the person's last press, for a moment.
117  pictures: true,
118  frameKey: '',
119  reaction: undefined as { kind: Reaction; until: number } | undefined,
120  // Unanswered items while the pane is off screen: what the hint line shows.
121  offScreen: 0,
122}
123
124const SECTION = {
125  id: 'needs-you:pane',
126  scope: 'session',
127  text: [
128    '# The Needs-you pane',
129    `The person reads a side pane, not your transcript, for anything that needs them. Whenever you need the person to DECIDE something, DO something outside this chat (mint a key, click approve, pay, reply to someone), or KNOW something that blocks or changes the work, call ${TOOL_PIN} at that moment, not at the end of a long reply. One item per call. Plain words a stranger would follow: no code names, paths or jargon unless they must act on them; what and why, one line each. A decision lists 2 to 4 options, your recommendation first.`,
130    `A message containing [needs-you] is the person answering an item from the pane: act on it and call ${TOOL_RESOLVE} with that number once handled. A note on an item is added context: take it into account, and resolve only once the item is settled. One that says no reply is needed needs none. If the question of an open item changes, call ${TOOL_UPDATE}; do not pin it again. Never repeat an open item's question in prose; say "waiting on #3 in the pane" and move on. ${TOOL_LIST} shows what is open. Subagents pin too; the pane shows who asked.`,
131  ].join('\n\n'),
132} as const
133
134export const register: Register = on => {
135  let timer: Timer | undefined
136
137  // Registered first, so it runs for every tool, the pane's own included (the
138  // pane's tool hooks below answer without calling next). A tool call ends the
139  // narration of the turn so far: blocks drawn before it fold once it is over.
140  on('tool.call', async ($, e, next) => {
141    if (e.agentId === undefined) local.cutIndex = local.assistantIds.length
142    return next(e)
143  })
144
145  on('session.start', async ($, e, next) => {
146    local.sessionId = await $.session.id()
147    await restore($, false)
148    $.clock.after(0, () => {
149      void pruneStore($).catch(() => undefined)
150    })
151    await Promise.all([
152      $.tool.register({
153        name: 'pin',
154        description:
155          'Pin something the person must decide, do outside this chat, or know. It shows in their Needs-you pane; they answer there. Plain words, one item per call.',
156        isDeferred: false,
157        inputSchema: {
158          type: 'object',
159          properties: {
160            kind: {
161              type: 'string',
162              enum: ['decide', 'do', 'know'],
163              description: 'decide: they pick. do: they act outside this chat. know: it blocks or changes the work.',
164            },
165            title: { type: 'string', description: 'One plain line, under 100 characters, no jargon.' },
166            detail: { type: 'string', description: 'One or two plain sentences: why, and what happens either way. Optional.' },
167            options: {
168              type: 'array',
169              items: { type: 'string' },
170              description: 'decide only: 2 to 4 short options, your recommendation first.',
171            },
172          },
173          required: ['kind', 'title'],
174        },
175      }),
176      $.tool.register({
177        name: 'resolve',
178        description: "Mark a Needs-you item handled, by its number. Call it once you have acted on the person's answer.",
179        isDeferred: false,
180        inputSchema: { type: 'object', properties: { n: { type: 'number' } }, required: ['n'] },
181      }),
182      $.tool.register({
183        name: 'update',
184        description: 'Change the title, detail or options of an open Needs-you item, by its number. Use it when the question changes instead of pinning it again.',
185        isDeferred: false,
186        inputSchema: {
187          type: 'object',
188          properties: {
189            n: { type: 'number' },
190            title: { type: 'string' },
191            detail: { type: 'string' },
192            options: { type: 'array', items: { type: 'string' } },
193          },
194          required: ['n'],
195        },
196      }),
197      $.tool.register({
198        name: 'list',
199        description: 'List the open Needs-you items and what the person has answered so far.',
200        isDeferred: false,
201        inputSchema: { type: 'object' },
202      }),
203      $.command.register({
204        name: 'needs-you',
205        description: 'Needs-you pane: open it, focus on|off, add <text>, clear, catch on|off',
206        argumentHint: '[focus on|off] [add <text>] [clear] [catch on|off]',
207        immediate: true,
208      }),
209    ])
210    // Open at startup (a new session, or a reload) only when restored items are
211    // waiting; otherwise the first pin or /needs-you opens it.
212    if (openItems(await read($, items)).length > 0) {
213      const opened = await $.ui.open(await openArgs($))
214      local.paneIsPlaced = opened.isPlaced
215    } else {
216      local.paneIsPlaced = (await $.ui.panes()).some(p => p.id === PANE)
217    }
218    timer?.cancel()
219    timer = $.clock.every(TICK_MS, () => {
220      void tick($).catch(() => undefined)
221    })
222    return next(e)
223  })
224
225  // The person's close (the mark, Esc, or the ✕ chip) sticks until the next pin.
226  on('ui.close', async ($, e, next) => {
227    if (e.id === PANE) {
228      local.paneIsPlaced = false
229      await signal($)
230    }
231    return next(e)
232  })
233
234  // A /clear ends the conversation, not the session: the list empties and the
235  // tick keeps running (no session.start follows). The next turn.start picks up
236  // the new session id.
237  on('session.end', async ($, e, next) => {
238    if (e.reason === 'clear') {
239      await update($, items, () => [])
240      await update($, interim, () => [])
241      local.assistantIds = []
242      local.knownIds.clear()
243      local.wokeFor = ''
244    } else {
245      timer?.cancel()
246      timer = undefined
247    }
248    return next(e)
249  })
250
251  // ---- the model's side: pin, resolve, update, list ------------------------
252
253  on('tool.call', { tool: TOOL_PIN }, async ($, e) => {
254    const parsed = parsePin(e as Record<string, unknown>)
255    if (!parsed.ok) return { result: `Not pinned: ${parsed.why}.` }
256    const { item, isPlaced } = await pin($, parsed.pin, { agentId: e.agentId })
257    return { result: pinReply(item, isPlaced) }
258  }).catch(() => ({ result: 'Not pinned: the Needs-you pane failed. Ask the person in one plain line instead.' }))
259
260  on('tool.call', { tool: TOOL_RESOLVE }, async ($, e) => {
261    const n = Number((e as Record<string, unknown>).n)
262    const list = await read($, items)
263    const item = findByN(list, n)
264    if (!item) return { result: `No item #${n}.` }
265    await setItems($, cur => cur.map(i => (i.n === n ? { ...i, status: 'done' as const } : i)))
266    return { result: `#${n} resolved.` }
267  }).catch(() => ({ result: 'The Needs-you pane failed to resolve that item; carry on.' }))
268
269  on('tool.call', { tool: TOOL_UPDATE }, async ($, e) => {
270    const args = e as Record<string, unknown>
271    const n = Number(args.n)
272    const list = await read($, items)
273    const item = findByN(list, n)
274    if (!item || item.status === 'done') return { result: `No open item #${n}.` }
275    const parsed = parsePin({
276      kind: item.kind,
277      title: args.title ?? item.title,
278      detail: args.detail ?? item.detail,
279      options: args.options ?? item.options,
280    })
281    if (!parsed.ok) return { result: `Not changed: ${parsed.why}.` }
282    await setItems($, cur => cur.map(i => (i.n === n ? { ...i, ...parsed.pin } : i)))
283    return { result: `#${n} updated.` }
284  }).catch(() => ({ result: 'The Needs-you pane failed to update that item; carry on.' }))
285
286  on('tool.call', { tool: TOOL_LIST }, async $ => ({ result: listReply(await read($, items)) })).catch(() => ({
287    result: 'The Needs-you pane is not answering.',
288  }))
289
290  on('prompt.compose', async ($, e, next) => {
291    const r = await next(e)
292    return { sections: [...r.sections, SECTION] }
293  })
294
295  // ---- the turn: what is running, what has been read -----------------------
296
297  on('turn.start', async ($, e, next) => {
298    if (e.agentId === undefined) {
299      // A /clear or an in-process resume changes the session without a
300      // session.start: follow it, so the list is that session's.
301      const sid = await $.session.id()
302      if (sid !== local.sessionId) {
303        local.sessionId = sid
304        await restore($, true)
305      }
306      for (const id of local.assistantIds) local.knownIds.add(id)
307      local.assistantIds = []
308      local.cutIndex = 0
309      local.isTurnRunning = true
310      await update($, live, l => ({ ...l, isTurnRunning: true, turnId: e.turnId, pinsThisTurn: 0 }))
311    }
312    return next(e)
313  })
314
315  // Every model request carries what was handed to its loop before it started.
316  on('turn.step', async function* ($, e, next) {
317    const at = await $.clock.now()
318    const list = await read($, items)
319    const seen = markSeen(list, e.turnId, at, e.agentId, local.holding)
320    if (seen.some((i, k) => i !== list[k])) await setItems($, cur => markSeen(cur, e.turnId, at, e.agentId, local.holding))
321    return yield* next(e)
322  })
323
324  on('turn.complete', async ($, e, next) => {
325    const r = await next(e)
326    if (e.agentId !== undefined) {
327      await rescueFromAgent($, e.agentId)
328      return r
329    }
330    const l = await read($, live)
331    local.isTurnRunning = false
332    await update($, live, x => ({ ...x, isTurnRunning: false }))
333    const fold = local.assistantIds.slice(0, local.cutIndex)
334    for (const id of local.assistantIds) local.knownIds.add(id)
335    local.assistantIds = []
336    local.cutIndex = 0
337    if (fold.length) await update($, interim, prev => [...prev, ...fold.filter(id => !prev.includes(id))].slice(-INTERIM_CAP))
338    const list = await read($, items)
339    const kept = archiveSeen(list, e.turnId)
340    if (kept.some((i, k) => i !== list[k])) await setItems($, cur => archiveSeen(cur, e.turnId))
341    const s = await read($, settings)
342    if (s.catch && e.reason === 'answer' && l.pinsThisTurn === 0 && hasCue(e.answer)) {
343      const answer = e.answer
344      const turnId = e.turnId
345      $.clock.after(0, () => {
346        void catchFromReply($, answer, turnId).catch(() => undefined)
347      })
348    }
349    // An answer that reached the conversation during the turn's last request
350    // is read by nothing until the next turn: start one. Only after a turn that
351    // answered: not after the person stopped it, nor after an error.
352    if (e.reason === 'answer') {
353      $.clock.after(0, () => {
354        void wakeIfStranded($).catch(() => undefined)
355      })
356    }
357    return r
358  })
359
360  // ---- the person's side: the slash command -------------------------------
361
362  on('command.run', { command: 'needs-you' }, async ($, e) => {
363    const { verb, rest } = parseCommand(e.args)
364    const s = await read($, settings)
365    if (verb === 'focus') {
366      const focus = rest === 'off' ? false : rest === 'on' ? true : !s.focus
367      await setSettings($, { focus })
368      $.ui.invalidate('ui.render')
369      return {
370        text: focus
371          ? 'Focus on: tool rows and working notes are hidden (ctrl+o still shows all).'
372          : 'Focus off: the full transcript is back.',
373      }
374    }
375    if (verb === 'catch') {
376      const isOn = rest !== 'off'
377      await setSettings($, { catch: isOn })
378      return { text: isOn ? 'Reply catcher on: asks that were not pinned get pinned after each reply.' : 'Reply catcher off.' }
379    }
380    if (verb === 'add' && rest) {
381      const { item } = await pin($, { kind: 'do', title: oneLine(rest).slice(0, 140), detail: '', options: [] }, { from: 'you', isCounted: false })
382      return { text: `Pinned #${item.n}: ${item.title}` }
383    }
384    if (verb === 'clear') {
385      await setItems($, cur => cur.map(i => (i.status === 'open' ? i : { ...i, status: 'done' as const })))
386      return { text: 'Answered items cleared.' }
387    }
388    const opened = await $.ui.open({ ...(await openArgs($)), focus: true })
389    local.paneIsPlaced = opened.isPlaced
390    const open = openItems(await read($, items)).length
391    return {
392      text: opened.isPlaced
393        ? `Needs-you pane open: ${open} item${open === 1 ? '' : 's'} waiting. Focus mode is ${s.focus ? 'on' : 'off'}.`
394        : `The pane could not be drawn here (${opened.reason}).`,
395    }
396  })
397
398  // ---- focus mode: a quiet transcript -------------------------------------
399  // The terminal alone: ctrl+o (isExpanded) shows what is hidden there. Other
400  // surfaces have no such key, so their transcript stays whole.
401
402  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
403    if (e.surface !== 'terminal') return next(e)
404    const s = await read($, settings)
405    if (!s.focus || e.props.isExpanded === true || e.props.isErrored || e.props.isInterrupted) return next(e)
406    const { Box } = $.ui.resolve(e)
407    return <Box display="none" />
408  })
409
410  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
411    if (e.surface !== 'terminal') return next(e)
412    const s = await read($, settings)
413    if (!s.focus || e.props.isExpanded === true || e.props.isErrored) return next(e)
414    const { Box } = $.ui.resolve(e)
415    return <Box display="none" />
416  })
417
418  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
419    if (e.surface !== 'terminal') return next(e)
420    const s = await read($, settings)
421    if (!s.focus || e.props.isExpanded) return next(e)
422    const { Box } = $.ui.resolve(e)
423    return <Box display="none" />
424  })
425
426  // While items wait and the pane is off screen, a few dim characters at the
427  // end of the hint line under the prompt (`? for shortcuts`) say so: no row of
428  // their own, nothing pinned.
429  on('ui.render', { component: 'PromptHint' }, async (_$, e, next) => {
430    if (!local.offScreen) return next(e)
431    const tail = `${e.props.tail ?? ''}  ● ${local.offScreen} needs you`
432    return next({ ...e, props: { ...e.props, tail } })
433  })
434
435  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
436    const s = await read($, settings)
437    return s.focus ? next({ ...e, props: { ...e.props, hint: '' } }) : next(e)
438  })
439
440  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
441    // Only a block first drawn while the main turn runs is that turn's; a
442    // re-draw of an older one (scroll, resize, invalidate) is not.
443    const id = e.requestId
444    if (!local.knownIds.has(id) && !local.assistantIds.includes(id)) {
445      if (local.isTurnRunning) local.assistantIds.push(id)
446      else local.knownIds.add(id)
447    }
448    if (e.surface !== 'terminal') return next(e)
449    const s = await read($, settings)
450    const folded = await read($, interim)
451    if (!s.focus || !folded.includes(id)) return next(e)
452    const { Box, Text } = $.ui.resolve(e)
453    const width = Math.max(20, (e.viewport?.columns ?? 80) - 6)
454    return (
455      <Box paddingLeft={2}>
456        <Text dimColor wrap="truncate">
457          · {firstLine(e.props.text, width)}
458        </Text>
459      </Box>
460    )
461  })
462
463  // ---- the pane -----------------------------------------------------------
464
465  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
466    if (!local.paneIsPlaced) {
467      local.paneIsPlaced = true
468      await signal($)
469    }
470    const [list, s, l] = await Promise.all([read($, items), read($, settings), read($, live)])
471    const now = await $.clock.now()
472    const open = openItems(list)
473    const done = doneItems(list)
474    // Only an unanswered item waits on the person; a sent or seen one is Claude's.
475    const waiting = open.filter(i => i.status === 'open').length
476    const width = Math.max(24, e.props.bodyColumns)
477    const face = { open: open.length, isFresh: now - l.lastPinAt < FRESH_MS, tick: Math.floor(now / 900), seed: open.length * 7 + done.length }
478    const frame = frameAt(await scene($))
479    local.frameKey = frameKey(frame)
480    const grid = draw(frame)
481    const tone = open.length ? 'claude' : 'inactive'
482
483    if (e.surface === 'mobile') {
484      const { Box, Text, Button } = $.ui.resolve(e)
485      return (
486        <Box flexDirection="column">
487          <Text color={tone}>
488            {avatarLine(face)} Needs you · {open.length} open
489          </Text>
490          {open.map(i => {
491            // Answered: a non-pressable [~] and where the answer is. Open: a
492            // decision shows its options, the first being the recommendation;
493            // anything else a box to tick.
494            const isOpen = i.status === 'open'
495            const state = i.status === 'sent' ? ' · sending' : i.status === 'seen' ? ' · Claude has it' : ''
496            return (
497              <Box key={`row:${i.id}`} flexDirection="column">
498                <Box flexDirection="row" gap={1}>
499                  {!isOpen ? (
500                    <Text dimColor>[~]</Text>
501                  ) : i.options.length === 0 ? (
502                    <Button key={`d:${i.id}`} plain label="[ ]" onPress={() => void answer($, i.id, { kind: 'check' })} />
503                  ) : null}
504                  <Text dimColor={!isOpen}>
505                    {i.n} {i.title}
506                    {state}
507                  </Text>
508                </Box>
509                {isOpen && i.options.length > 0 && (
510                  <Box flexDirection="row" flexWrap="wrap" gap={1} paddingLeft={2}>
511                    {i.options.map((o, k) => (
512                      <Button
513                        key={`o:${i.id}:${k}`}
514                        plain
515                        label={k === 0 ? `★ ${clip(o, OPTION_COLS)}` : clip(o, OPTION_COLS)}
516                        onPress={() => void answer($, i.id, { kind: 'option', text: o })}
517                      />
518                    ))}
519                  </Box>
520                )}
521              </Box>
522            )
523          })}
524        </Box>
525      )
526    }
527
528    const ui = $.ui.resolve(e)
529    const { Box, Text, Button, Input } = ui
530    // The terminal draws Clawd as a picture (kitty graphics), or as half-block
531    // cells once a blit says pictures cannot be drawn; other surfaces get the face.
532    const mascot =
533      'Image' in ui && local.pictures ? (
534        <ui.Image key="clawd" source={toRgba(grid, PIC_SCALE)} columns={PIC_COLS} rows={PIC_ROWS} alt="Claude" />
535      ) : 'Raster' in ui ? (
536        <ui.Raster key="clawd" columns={RASTER_COLS} rows={RASTER_ROWS} cells={toRaster(grid)} />
537      ) : (
538        <Text color={tone}>{avatarLine(face)}</Text>
539      )
540    const bodyRows = e.props.scroll.bodyRows
541    // A chip: a compact button drawn as a filled label, one space of padding,
542    // instead of the engine's wide `[ label ]`. The focus and the pointer invert it.
543    // No hotkeys: the engine prefixes a plain button with `a:` for one, which clutters the chip.
544    const chip = (key: string, text: string, onPress: () => void, o: { hue?: string; dim?: boolean } = {}) => (
545      <Button key={key} plain {...(o.dim ? { dimColor: true } : {})} onPress={onPress}>
546        <Text backgroundColor={o.hue ?? INK.chip} color={o.hue ? INK.pane : INK.chipText} bold={Boolean(o.hue)}>
547          {` ${text} `}
548        </Text>
549      </Button>
550    )
551
552    if (s.view === 'icon') {
553      return (
554        <Box flexDirection="column" alignItems="flex-start" backgroundColor={INK.pane} width={width} minHeight={bodyRows} paddingX={1}>
555          {mascot}
556          <Box flexDirection="row" gap={1}>
557            <Text bold color={open.length ? INK.decide : INK.mute}>
558              {waiting ? `${waiting} waiting` : 'all clear'}
559            </Text>
560            {chip('view', '▸ open', () => void setSettings($, { view: 'list' }))}
561          </Box>
562        </Box>
563      )
564    }
565
566    return (
567      <Box flexDirection="column" backgroundColor={INK.pane} width={width} minHeight={bodyRows} paddingX={1}>
568        <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
569          <Box flexDirection="row" gap={1}>
570            <Text bold color={INK.do}>
571              Needs you
572            </Text>
573            {/* Under 40 columns the count alone, so the header keeps one row. */}
574            <Text dimColor>
575              {width < 40 ? (waiting ? String(waiting) : '') : waiting ? `${waiting} waiting on you` : open.length ? 'with Claude' : 'nothing waiting'}
576            </Text>
577          </Box>
578          <Box flexDirection="row" gap={1}>
579            {chip('view', '─', () => void setSettings($, { view: 'icon' }))}
580            {chip('close', '✕', () => void closePane($))}
581          </Box>
582        </Box>
583
584        {open.length === 0 && (
585          <Box flexDirection="column" paddingX={1}>
586            <Text dimColor>Claude pins what it needs from you here.</Text>
587            <Text dimColor>Try: /needs-you add something</Text>
588          </Box>
589        )}
590
591        {open.map(i => {
592          // An open item is a full card: title, why, the actions, a note line.
593          // Once answered it folds to one line that says where the answer is.
594          const hue = kindHue(i.kind)
595          const isOpen = i.status === 'open'
596          const state = i.status === 'sent' ? '… sending' : '✓ Claude has it'
597          const tick = i.kind === 'decide' ? 'go with it' : i.kind === 'do' ? 'done' : 'got it'
598          const fromAgent = i.from && i.from !== 'you' && i.from !== 'Claude' ? i.from : ''
599          if (!isOpen) {
600            return (
601              <Box key={`row:${i.id}`} flexDirection="row" marginBottom={1}>
602                <Box width={1} flexShrink={0} backgroundColor={INK.mute} />
603                <Box flexDirection="row" flexGrow={1} gap={1} paddingX={1} justifyContent="space-between">
604                  <Box flexGrow={1} flexShrink={1}>
605                    <Text dimColor wrap="truncate-end">
606                      {i.n} {i.title}
607                      {i.answer ? ` → ${i.answer}` : ''}
608                    </Text>
609                  </Box>
610                  <Box flexShrink={0}>
611                    <Text dimColor>{state}</Text>
612                  </Box>
613                </Box>
614              </Box>
615            )
616          }
617          return (
618            <Box key={`row:${i.id}`} flexDirection="row" marginBottom={1}>
619              <Box width={1} flexShrink={0} backgroundColor={hue} />
620              <Box flexDirection="column" flexGrow={1} backgroundColor={INK.card} paddingX={1}>
621                <Box flexDirection="row" gap={1}>
622                  <Text backgroundColor={hue} color={INK.pane} bold>
623                    {` ${i.n} `}
624                  </Text>
625                  <Box flexGrow={1} width={width - 10}>
626                    <Text wrap="wrap" bold>
627                      {i.title}
628                      {fromAgent ? <Text dimColor>{`  from ${fromAgent}`}</Text> : null}
629                    </Text>
630                  </Box>
631                </Box>
632                {i.detail !== '' && (
633                  <Box width={width - 6} paddingLeft={4}>
634                    <Text wrap="wrap" dimColor>
635                      {i.detail}
636                    </Text>
637                  </Box>
638                )}
639                <Box flexDirection="row" flexWrap="wrap" gap={1} paddingLeft={4} marginTop={1}>
640                  {i.options.length > 0
641                    ? i.options.map((o, k) =>
642                        chip(
643                          `o:${i.id}:${k}`,
644                          k === 0 ? `★ ${clip(o, OPTION_COLS)}` : clip(o, OPTION_COLS),
645                          () => void answer($, i.id, { kind: 'option', text: o }),
646                          k === 0 ? { hue } : {},
647                        ),
648                      )
649                    : chip(`d:${i.id}`, `✓ ${tick}`, () => void answer($, i.id, { kind: 'check' }), { hue })}
650                  {chip(`c:${i.id}`, '💬 chat', () => void answer($, i.id, { kind: 'chat' }))}
651                </Box>
652                {i.notes.map((n, k) => (
653                  <Box key={`note:${i.id}:${k}`} paddingLeft={4}>
654                    <Text dimColor wrap="wrap">
655                      {n.status === 'seen' ? '✓' : '…'} {n.text}
656                    </Text>
657                  </Box>
658                ))}
659                {/* The field needs a width of its own, or a long note runs off the card:
660                    pane padding (2), the colour bar (1), card padding (2), the indent (4). */}
661                <Box flexDirection="row" paddingLeft={4} width={width - 2}>
662                  <Input
663                    key={`n:${i.id}`}
664                    placeholder="✎ add a note"
665                    submitLabel=" "
666                    rows={2}
667                    value={local.drafts[i.id] ?? ''}
668                    onInput={v => {
669                      local.drafts[i.id] = v
670                    }}
671                    onSubmit={v => void answer($, i.id, { kind: 'note', text: v })}
672                  />
673                </Box>
674              </Box>
675            </Box>
676          )
677        })}
678
679        {done.length > 0 && (
680          <Box flexDirection="column" marginTop={1}>
681            {/* A link-like toggle, not a chip: a filled chip keeps the engine's
682                focus inversion after the press and reads as "selected". */}
683            <Button key="done" plain dimColor onPress={() => void setSettings($, { showDone: !s.showDone })}>
684              {s.showDone ? `▴ hide ${done.length} done` : `▾ show ${done.length} done`}
685            </Button>
686            {s.showDone ? (
687              <Box flexDirection="column">
688                {done.slice(-12).map(i => (
689                  <Text key={`done:${i.id}`} dimColor strikethrough>
690                    {String(i.n)} {i.title}
691                  </Text>
692                ))}
693              </Box>
694            ) : null}
695          </Box>
696        )}
697      </Box>
698    )
699  })
700}
701
702// ---- helpers that touch the engine ----------------------------------------
703
704// What the store keeps per session: the list's not-done items under a shape
705// tag, and when it was written. A value of another shape is declined.
706type Stored = { shape: string; at: number; items: Item[] }
707
708function asStored(raw: unknown): Stored | undefined {
709  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined
710  const v = raw as Partial<Stored>
711  return v.shape === STORE_SHAPE && Array.isArray(v.items) && typeof v.at === 'number' ? (v as Stored) : undefined
712}
713
714// The session's stored list. On a change of session (`isNewSession`) the list
715// becomes that session's, empty when it has none; at a (re)load a stored list
716// replaces the one in memory only when there is one.
717async function restore($: $T, isNewSession: boolean) {
718  const stored = asStored(await $.store.get(`items:${local.sessionId}`).catch(() => undefined))
719  if (stored && stored.items.length) await update($, items, () => stored.items)
720  else if (isNewSession) await update($, items, () => [])
721  if (isNewSession) {
722    await update($, interim, () => [])
723    local.wokeFor = ''
724  }
725  const s = (await $.store.get('settings').catch(() => undefined)) as Partial<PaneSettings> | undefined
726  if (s && typeof s === 'object') await update($, settings, cur => ({ ...cur, ...s }))
727  await resize($)
728}
729
730// Other sessions' lists: drop those of another shape, empty, or older than
731// KEEP_MS, and all but the newest KEEP_SESSIONS, so the store stays small.
732async function pruneStore($: $T) {
733  const now = await $.clock.now()
734  const mine = `items:${local.sessionId}`
735  const kept: { key: string; at: number }[] = []
736  for (const key of await $.store.keys()) {
737    if (!key.startsWith('items:') || key === mine) continue
738    const v = asStored(await $.store.get(key))
739    if (v && v.items.length && now - v.at < KEEP_MS) kept.push({ key, at: v.at })
740    else await $.store.delete(key)
741  }
742  kept.sort((a, b) => b.at - a.at)
743  for (const { key } of kept.slice(KEEP_SESSIONS)) await $.store.delete(key)
744}
745
746// Every change to the list goes through one atomic update over the latest
747// value: a hook that read the list before a tool call wrote it (a turn step
748// marking "seen" around a resolve) must not put the old list back.
749async function setItems($: $T, fn: (list: readonly Item[]) => Item[]) {
750  const next = await update($, items, fn)
751  // The list in memory is what counts; a failed save (a full store) costs only
752  // the copy a restart would read, so it is said once and the work goes on.
753  try {
754    const value: Stored = { shape: STORE_SHAPE, at: await $.clock.now(), items: next.filter(i => i.status !== 'done') }
755    await $.store.set(`items:${local.sessionId}`, value)
756  } catch (err) {
757    if (!local.storeWarned) {
758      local.storeWarned = true
759      $.ui.toast(`Needs you: could not save the list (${String(err)}); it is kept for this session only.`)
760    }
761  }
762  await resize($)
763  await signal($)
764}
765
766async function setSettings($: $T, patch: Partial<PaneSettings>) {
767  const next = await update($, settings, s => ({ ...s, ...patch }))
768  await $.store.set('settings', next)
769  await resize($)
770}
771
772async function title($: $T): Promise<string> {
773  const open = openItems(await read($, items)).length
774  return open ? `Needs you (${open})` : 'Needs you'
775}
776
777async function rows($: $T): Promise<number> {
778  return wantedRows(await read($, items), await read($, settings))
779}
780
781// The pane follows its content: a re-open with the same id only re-sizes. A
782// pane the person closed stays closed; the next pin brings it back.
783async function resize($: $T) {
784  if (!local.paneIsPlaced) return
785  const isUp = (await $.ui.panes()).some(p => p.id === PANE)
786  if (!isUp) {
787    local.paneIsPlaced = false
788    return
789  }
790  await $.ui.open(await openArgs($))
791}
792
793// Every open of the pane: its title and size follow the content, and Escape
794// closes it while it holds the keyboard (a running turn or typed text keeps
795// Escape for the main session).
796async function openArgs($: $T) {
797  const s = await read($, settings)
798  return {
799    id: PANE,
800    title: await title($),
801    columns: s.view === 'icon' ? 24 : COLUMNS,
802    rows: await rows($),
803    closeOnEscape: true as const,
804  }
805}
806
807// While items wait and the pane is not on screen (closed by hand, or an unasked
808// open held back on a narrow terminal), a pinned line under the prompt says so.
809async function signal($: $T) {
810  const waiting = openItems(await read($, items)).filter(i => i.status === 'open').length
811  const next = waiting > 0 && !local.paneIsPlaced ? waiting : 0
812  if (next !== local.offScreen) {
813    local.offScreen = next
814    $.ui.invalidate('ui.render')
815  }
816}
817
818async function closePane($: $T) {
819  local.paneIsPlaced = false
820  try {
821    await $.ui.close({ id: PANE })
822  } catch (err) {
823    $.ui.toast(`Needs you: could not close (${String(err)})`)
824  }
825  await signal($)
826}
827
828// A new item. `isCounted: false` (the reply catcher, the person's own `add`)
829// leaves the turn's pin count alone, which only the model's pins should move.
830async function pin(
831  $: $T,
832  p: Pin,
833  o: { agentId?: string; from?: string; isCounted?: boolean } = {},
834): Promise<{ item: Item; isPlaced: boolean }> {
835  const now = await $.clock.now()
836  const list = await read($, items)
837  const item = newItem(list, p, o.from ?? (await whoAsked($, o.agentId)), now, o.agentId)
838  await setItems($, cur => [...cur, item])
839  const counted = o.isCounted === false ? 0 : 1
840  await update($, live, l => ({ ...l, pinsThisTurn: l.pinsThisTurn + counted, lastPinAt: now }))
841  const opened = await $.ui.open(await openArgs($))
842  local.paneIsPlaced = opened.isPlaced
843  if (!opened.isPlaced) $.ui.toast(`Needs you #${item.n}: ${item.title} (/needs-you)`, { timeoutMs: 8000 })
844  await signal($)
845  return { item, isPlaced: opened.isPlaced }
846}
847
848async function whoAsked($: $T, agentId?: string): Promise<string> {
849  if (!agentId) return ''
850  try {
851    const agent = (await $.agent.list()).find(a => a.id === agentId)
852    return agent ? `${agent.name ?? agent.type} (${agent.description})` : 'a helper agent'
853  } catch {
854    return 'a helper agent'
855  }
856}
857
858// The person acted on an item. One answer per item at a time: a second press
859// while the first is still being handled (a double press, the same chip drawn
860// on two surfaces) is dropped.
861async function answer($: $T, id: string, given: Answer) {
862  if (local.inFlight.has(id)) return
863  local.inFlight.add(id)
864  try {
865    await handleAnswer($, id, given)
866  } finally {
867    local.inFlight.delete(id)
868    local.holding.delete(id)
869  }
870}
871
872async function handleAnswer($: $T, id: string, given: Answer) {
873  const item = (await read($, items)).find(i => i.id === id)
874  if (!item || item.status === 'done') return
875  let a = normalize(item, given)
876  // A tick or a choice answers the item once; after that it is on its way.
877  if ((a.kind === 'check' || a.kind === 'option') && item.status !== 'open') return
878  if (a.kind === 'note') {
879    const text = oneLine(a.text)
880    if (!text) return
881    a = { kind: 'note', text }
882  }
883  const now = await $.clock.now()
884  if (a.kind === 'chat') {
885    if (item.chatAt !== undefined || now - (local.chatAt[id] ?? -CHAT_MS) < CHAT_MS) return
886    local.chatAt[id] = now
887  }
888  react(a.kind === 'check' ? 'love' : a.kind === 'option' ? 'ok' : a.kind === 'note' ? 'note' : 'chat', now)
889
890  // The route: to the helper agent that asked (a chat goes to the main session,
891  // where the person talks), appended to the running turn (a quiet one to the
892  // conversation, running or not), or a prompt that starts a turn.
893  const quiet = isQuiet(item, a)
894  const agentId = a.kind === 'chat' ? undefined : item.agentId
895  const isRunning = (await read($, live)).isTurnRunning
896  // An appended row may miss a request that starts while it is on its way, so
897  // none counts as carrying it until the append is done. A prompt's turn starts
898  // after this, so its requests do carry it.
899  if (agentId || isRunning || quiet) local.holding.add(id)
900
901  const note = a.kind === 'note' ? { text: a.text, at: now, status: 'sent' as const } : undefined
902  await patchItem($, id, i => markSent(i, a, now, note))
903  if (note) local.drafts[id] = ''
904
905  const r = await deliver($, item, a, { agentId, quiet })
906  if ('failed' in r) {
907    await patchItem($, id, i => unmarkSent(i, item, a, note))
908    if (a.kind === 'note') local.drafts[id] = a.text
909    if (a.kind === 'chat') delete local.chatAt[id]
910    $.ui.toast(`Needs you #${item.n}: not delivered (${r.failed}). It is still open; try again.`)
911    return
912  }
913  const at = r.submitted ? now : await $.clock.now()
914  await patchItem($, id, i => stampSent(i, a, at, r.to, note))
915  local.holding.delete(id)
916  // The turn ended while the row went in: nothing will read it until a turn starts.
917  if (!r.submitted && !r.to && !quiet && !(await read($, live)).isTurnRunning) await wakeIfStranded($)
918}
919
920async function patchItem($: $T, id: string, fn: (i: Item) => Item) {
921  await setItems($, cur => cur.map(i => (i.id === id ? fn(i) : i)))
922}
923
924function sameNote(n: Note, note: Note | undefined): boolean {
925  return note !== undefined && n.at === note.at && n.text === note.text
926}
927
928// On its way: what the pane shows at once.
929function markSent(i: Item, a: Answer, now: number, note: Note | undefined): Item {
930  if (a.kind === 'check' || a.kind === 'option') {
931    const next: Item = { ...i, status: 'sent', sentAt: now, answer: a.kind === 'option' ? a.text : i.answer }
932    delete next.sentTo
933    return next
934  }
935  if (a.kind === 'note' && note) return { ...i, notes: [...i.notes, note] }
936  return { ...i, chatAt: now }
937}
938
939// Not delivered: back to how it was.
940function unmarkSent(i: Item, before: Item, a: Answer, note: Note | undefined): Item {
941  if (a.kind === 'check' || a.kind === 'option') {
942    if (i.status !== 'sent') return i
943    const next: Item = { ...i, status: before.status, answer: before.answer }
944    delete next.sentAt
945    delete next.sentTo
946    return next
947  }
948  if (a.kind === 'note') return { ...i, notes: i.notes.filter(n => !sameNote(n, note)) }
949  const next = { ...i }
950  delete next.chatAt
951  return next
952}
953
954// Handed over at `at`, to agent `to` (absent: the main conversation). What a
955// request has already carried stays as it is.
956function stampSent(i: Item, a: Answer, at: number, to: string | undefined, note: Note | undefined): Item {
957  if (a.kind === 'check' || a.kind === 'option') {
958    if (i.status !== 'sent') return i
959    const next: Item = { ...i, sentAt: at }
960    if (to) next.sentTo = to
961    return next
962  }
963  if (a.kind === 'note') {
964    return {
965      ...i,
966      notes: i.notes.map(n => (n.status === 'sent' && sameNote(n, note) ? { ...n, at, ...(to ? { to } : {}) } : n)),
967    }
968  }
969  return i.chatAt === undefined ? i : { ...i, chatAt: at }
970}
971
972type Delivered = { submitted: boolean; to?: string } | { failed: string }
973
974// Puts the answer in front of the model without interrupting anything. A
975// helper agent's item goes to that agent, the main session getting an FYI; if
976// the agent has finished, the main session takes the answer itself.
977async function deliver($: $T, item: Item, a: Answer, o: { agentId?: string; quiet: boolean }): Promise<Delivered> {
978  const hidden = messageFor(item, a)
979  if (o.agentId) {
980    const denied = await appendRow($, hidden, o.agentId)
981    if (denied === undefined) {
982      await appendRow($, fyiFor(item, a))
983      return { submitted: false, to: o.agentId }
984    }
985  }
986  if (o.quiet || (await read($, live)).isTurnRunning) {
987    const denied = await appendRow($, hidden)
988    return denied === undefined ? { submitted: false } : { failed: denied }
989  }
990  local.holding.delete(item.id)
991  try {
992    const r = await $.prompt.submit({ text: personMessageFor(item, a), asUser: true })
993    return r.drop !== undefined ? { failed: r.drop } : { submitted: true }
994  } catch (err) {
995    return { failed: String(err) }
996  }
997}
998
999// A hidden user row in a conversation (main, or `agentId`'s); the reason when
1000// it was refused or failed, undefined once it is stored.
1001async function appendRow($: $T, text: string, agentId?: string): Promise<string | undefined> {
1002  try {
1003    const r = await $.session.append({
1004      message: { type: 'user', content: [{ type: 'text', text }] },
1005      ...(agentId ? { agentId } : {}),
1006    })
1007    return r.deny
1008  } catch (err) {
1009    return String(err)
1010  }
1011}
1012
1013// A helper agent finished with answers handed to it that none of its requests
1014// carried: the main session takes them, as actionable rows.
1015async function rescueFromAgent($: $T, agentId: string) {
1016  const list = await read($, items)
1017  const texts: string[] = []
1018  for (const i of list) {
1019    if (i.status === 'sent' && i.sentTo === agentId) {
1020      texts.push(messageFor(i, i.answer ? { kind: 'option', text: i.answer } : { kind: 'check' }))
1021    }
1022    for (const n of i.notes) if (n.status === 'sent' && n.to === agentId) texts.push(messageFor(i, { kind: 'note', text: n.text }))
1023  }
1024  if (!texts.length) return
1025  for (const text of texts) await appendRow($, text)
1026  const at = await $.clock.now()
1027  await setItems($, cur =>
1028    cur.map(i => {
1029      let next = i
1030      if (i.status === 'sent' && i.sentTo === agentId) {
1031        next = { ...next, sentAt: at }
1032        delete next.sentTo
1033      }
1034      if (i.notes.some(n => n.status === 'sent' && n.to === agentId)) {
1035        next = {
1036          ...next,
1037          notes: next.notes.map(n => (n.status === 'sent' && n.to === agentId ? { text: n.text, at, status: n.status } : n)),
1038        }
1039      }
1040      return next
1041    }),
1042  )
1043  if (!(await read($, live)).isTurnRunning) await wakeIfStranded($)
1044}
1045
1046// Answers in the main conversation that no request carried, with the session
1047// idle: start a turn so they are read. Once per set of answers, so a wake-up
1048// that raced a request does not repeat.
1049async function wakeIfStranded($: $T) {
1050  if ((await read($, live)).isTurnRunning) return
1051  const key = pendingKey(await read($, items))
1052  if (!key) {
1053    local.wokeFor = ''
1054    return
1055  }
1056  if (key === local.wokeFor) return
1057  local.wokeFor = key
1058  void $.prompt.submit({ text: WAKE_TEXT }).catch(() => undefined)
1059}
1060
1061async function catchFromReply($: $T, answer: string, turnId: string) {
1062  const r = await $.model.complete({
1063    model: 'haiku',
1064    system: CATCH_SYSTEM,
1065    prompt: catchPrompt(answer),
1066    maxTokens: 700,
1067    effort: 'low',
1068    timeoutMs: 30_000,
1069  })
1070  if (!r.isAnswered) return
1071  const pins = parseCaught(r.text)
1072  // A new turn has started: the reply is old news, and that turn pins its own.
1073  if (!pins.length || (await read($, live)).turnId !== turnId) return
1074  const pinned: Item[] = []
1075  for (const p of pins) pinned.push((await pin($, p, { from: 'the last reply', isCounted: false })).item)
1076  // Tell the model, which never saw these pins, so it can resolve them.
1077  await appendRow($, caughtNote(pinned))
1078}
1079
1080async function tick($: $T) {
1081  if (!local.paneIsPlaced) return
1082  // Clawd is drawn in the hidden view only; nothing to animate in the list.
1083  if ((await read($, settings)).view !== 'icon') return
1084  const f = frameAt(await scene($))
1085  const key = frameKey(f)
1086  if (key === local.frameKey) return
1087  local.frameKey = key
1088  const g = draw(f)
1089  const r = local.pictures
1090    ? await $.ui.blit({ requestId: PANE, key: 'clawd', source: toRgba(g, PIC_SCALE), columns: PIC_COLS, rows: PIC_ROWS })
1091    : await $.ui.blit({ requestId: PANE, key: 'clawd', cells: toRaster(g), columns: RASTER_COLS, rows: RASTER_ROWS })
1092  if (r.deny && local.pictures && /alt/i.test(r.deny)) {
1093    local.pictures = false
1094    $.ui.invalidate('ui.render')
1095  }
1096}
1097
1098// What the mascot reacts to right now.
1099async function scene($: $T) {
1100  const [list, l] = await Promise.all([read($, items), read($, live)])
1101  const now = await $.clock.now()
1102  const open = openItems(list)
1103  const done = doneItems(list)
1104  if (local.reaction && local.reaction.until < now) local.reaction = undefined
1105  const sc = { open: open.length, isFresh: now - l.lastPinAt < FRESH_MS, at: now, seed: open.length * 7 + done.length }
1106  return local.reaction ? { ...sc, reaction: local.reaction.kind } : sc
1107}
1108
1109function react(kind: Reaction, now: number) {
1110  local.reaction = { kind, until: now + REACT_MS }
1111}
1112
1113function kindHue(kind: Kind): string {
1114  return kind === 'decide' ? INK.decide : kind === 'do' ? INK.do : INK.know
1115}
1116
hooks/clawd.ts 224 lines
1// Pixel Clawd: the orange Claude mascot on a 30×28 pixel grid (the same
2// proportions as DockCrab's Clawdy), wearing a random hat, holding a random
3// thing, dancing while something needs the person, reacting when they press.
4// Pure: the grid becomes RGBA bytes for a terminal picture, or half-block
5// cells for a Raster where pictures cannot be drawn. No engine in here.
6
7export const GRID_W = 30
8export const GRID_H = 28
9export const CLAY = '#D97757'
10export const INK = '#1F1E1D'
11const WHITE = '#F4F3EE'
12const GOLD = '#F2C14E'
13const PINK = '#F06A7A'
14const SKY = '#6CB4EE'
15const LEAF = '#5FB36A'
16const WOOD = '#A0643A'
17const STEEL = '#B8BCC6'
18const RED = '#D64550'
19
20export type Mood = 'asleep' | 'surprised' | 'dancing'
21export type Reaction = 'love' | 'ok' | 'note' | 'chat'
22export type Frame = {
23  mood: Mood
24  step: number // 0..3, the dance beat
25  hat: number
26  thing: number
27  emote: string // '', '!', '?', 'z', 'zz', 'heart', 'note'
28  reaction?: Reaction
29}
30
31type Grid = (string | undefined)[] // GRID_W * GRID_H colours
32
33const fill = (g: Grid, x: number, y: number, w: number, h: number, c: string): void => {
34  for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) {
35    const px = x + dx, py = y + dy
36    if (px >= 0 && px < GRID_W && py >= 0 && py < GRID_H) g[py * GRID_W + px] = c
37  }
38}
39const stamp = (g: Grid, x: number, y: number, rows: string[], map: Record<string, string>): void =>
40  rows.forEach((row, dy) => [...row].forEach((ch, dx) => { const c = map[ch]; if (c) fill(g, x + dx, y + dy, 1, 1, c) }))
41
42// ---- the body --------------------------------------------------------------
43
44function body(g: Grid, f: Frame): void {
45  const bob = f.mood === 'dancing' && f.step % 2 === 1 ? 1 : 0
46  const armUp = f.mood === 'surprised' ? -4 : f.mood === 'dancing' && (f.step === 1 || f.step === 2) ? -3 : 0
47  const y = 10 + bob
48  fill(g, 7, y, 16, 12, CLAY)
49  fill(g, 3, y + 4 + (f.mood === 'surprised' ? -4 : 0), 4, 4, CLAY) // left arm
50  fill(g, 23, y + 4 + armUp, 4, 4, CLAY) // right arm, the one that holds
51  // eyes
52  if (f.mood === 'asleep') {
53    fill(g, 9, y + 3, 2, 1, INK); fill(g, 19, y + 3, 2, 1, INK)
54  } else if (f.mood === 'surprised' || f.reaction === 'love') {
55    fill(g, 9, y + 1, 2, 3, INK); fill(g, 19, y + 1, 2, 3, INK)
56    if (f.reaction === 'love') { fill(g, 9, y + 1, 2, 3, PINK); fill(g, 19, y + 1, 2, 3, PINK) }
57  } else if (f.mood === 'dancing' && f.step === 3 && f.hat % 3 === 0) {
58    fill(g, 9, y + 2, 2, 2, INK); fill(g, 19, y + 3, 2, 1, INK) // wink
59  } else {
60    fill(g, 9, y + 2, 2, 2, INK); fill(g, 19, y + 2, 2, 2, INK)
61  }
62  // legs: two pairs that alternate while dancing
63  const a = f.mood === 'dancing' && f.step % 2 === 0 ? -1 : 0
64  const b = f.mood === 'dancing' && f.step % 2 === 1 ? -1 : 0
65  fill(g, 7, 22, 2, 4 + a, CLAY); fill(g, 17, 22, 2, 4 + a, CLAY)
66  fill(g, 11, 22, 2, 4 + b, CLAY); fill(g, 21, 22, 2, 4 + b, CLAY)
67}
68
69// ---- hats: drawn over the head (y 2..9), x 5..24 ----------------------------
70
71export const HATS: ReadonlyArray<{ name: string; draw: (g: Grid, bob: number) => void }> = [
72  { name: 'top hat', draw: (g, b) => { fill(g, 9, 2 + b, 12, 7, INK); fill(g, 6, 8 + b, 18, 2, INK); fill(g, 9, 6 + b, 12, 1, RED) } },
73  { name: 'crown', draw: (g, b) => stamp(g, 7, 3 + b, ['g....gg....g', 'gg..gggg..gg', 'gggggggggggg', 'gggggggggggg', 'g.rr.gg.rr.g', 'gggggggggggg', 'dddddddddddd'], { g: GOLD, r: RED, d: '#A67C00' }) },
74  { name: 'party cone', draw: (g, b) => stamp(g, 11, 1 + b, ['...yy...', '...pp...', '..pppp..', '..yyyy..', '.pppppp.', '.yyyyyy.', 'pppppppp'], { p: PINK, y: GOLD }) },
75  { name: 'cowboy', draw: (g, b) => { fill(g, 10, 3 + b, 10, 5, WOOD); fill(g, 4, 8 + b, 22, 2, WOOD); fill(g, 10, 6 + b, 10, 1, INK) } },
76  { name: 'chef', draw: (g, b) => { fill(g, 8, 2 + b, 14, 5, WHITE); fill(g, 6, 4 + b, 18, 3, WHITE); fill(g, 9, 7 + b, 12, 3, WHITE) } },
77  { name: 'propeller', draw: (g, b) => { fill(g, 9, 6 + b, 12, 4, SKY); fill(g, 9, 8 + b, 12, 1, RED); fill(g, 14, 4 + b, 2, 2, STEEL); fill(g, 10, 3 + b, 10, 1, GOLD) } },
78  { name: 'halo', draw: (g, b) => { fill(g, 9, 3 + b, 12, 1, GOLD); fill(g, 8, 4 + b, 1, 1, GOLD); fill(g, 21, 4 + b, 1, 1, GOLD); fill(g, 9, 5 + b, 12, 1, GOLD) } },
79  { name: 'wizard', draw: (g, b) => stamp(g, 8, 0 + b, ['......ss......', '.....ssss.....', '.....ssss.....', '....ssyyss....', '....ssssss....', '...ssssssss...', '...ssyyssss...', '..ssssssssss..', '..ssssssssss..', 'ssssssssssssss'], { s: '#5A4FB5', y: GOLD }) },
80  { name: 'bucket', draw: (g, b) => { fill(g, 8, 3 + b, 14, 6, STEEL); fill(g, 7, 9 + b, 16, 1, '#8A8E99'); fill(g, 10, 2 + b, 10, 1, '#8A8E99') } },
81  { name: 'headphones', draw: (g, b) => { fill(g, 8, 6 + b, 14, 1, INK); fill(g, 7, 7 + b, 1, 2, INK); fill(g, 22, 7 + b, 1, 2, INK); fill(g, 5, 10 + b, 3, 5, INK); fill(g, 22, 10 + b, 3, 5, INK); fill(g, 6, 11 + b, 1, 3, SKY); fill(g, 23, 11 + b, 1, 3, SKY) } },
82  { name: 'viking', draw: (g, b) => { fill(g, 8, 6 + b, 14, 4, STEEL); fill(g, 5, 3 + b, 2, 5, WHITE); fill(g, 7, 6 + b, 1, 2, WHITE); fill(g, 23, 3 + b, 2, 5, WHITE); fill(g, 22, 6 + b, 1, 2, WHITE); fill(g, 8, 8 + b, 14, 1, GOLD) } },
83  { name: 'beanie', draw: (g, b) => { fill(g, 9, 4 + b, 12, 6, LEAF); fill(g, 8, 8 + b, 14, 2, '#3E8A48'); fill(g, 14, 2 + b, 2, 2, WHITE) } },
84  { name: 'bow', draw: (g, b) => stamp(g, 14, 5 + b, ['pp..pp', 'pppppp', '.pppp.', 'pppppp', 'pp..pp'], { p: PINK }) },
85  { name: 'none', draw: () => undefined },
86]
87
88// ---- things held in the right claw: x 24..29 -------------------------------
89
90export const THINGS: ReadonlyArray<{ name: string; draw: (g: Grid, y: number, step: number) => void }> = [
91  { name: 'wrench', draw: (g, y) => { fill(g, 27, y - 6, 2, 8, STEEL); fill(g, 26, y - 7, 4, 2, STEEL); fill(g, 27, y - 7, 2, 1, INK) } },
92  { name: 'coffee', draw: (g, y, s) => { fill(g, 25, y - 5, 5, 5, WHITE); fill(g, 26, y - 4, 3, 1, WOOD); fill(g, 29, y - 4, 1, 3, WHITE); if (s % 2) { fill(g, 26, y - 7, 1, 1, '#9A9A9A'); fill(g, 28, y - 8, 1, 1, '#9A9A9A') } } },
93  { name: 'pizza', draw: (g, y) => stamp(g, 24, y - 6, ['yyyyyy', '.rrrr.', '.rprp.', '..rr..', '..pp..', '...r..'], { y: GOLD, r: '#E8A24A', p: RED }) },
94  { name: 'balloon', draw: (g, y, s) => { const d = s % 2; fill(g, 25, y - 10 + d, 4, 5, RED); fill(g, 26, y - 11 + d, 2, 1, RED); fill(g, 26, y - 5 + d, 2, 1, RED); fill(g, 27, y - 4 + d, 1, 4, WHITE) } },
95  { name: 'flag', draw: (g, y, s) => { fill(g, 27, y - 9, 1, 11, WOOD); fill(g, 28, y - 9, s % 2 ? 2 : 3, 4, SKY) } },
96  { name: 'magnifier', draw: (g, y) => { stamp(g, 25, y - 8, ['.kkk.', 'k.b.k', 'kbbbk', 'k.b.k', '.kkk.'], { k: INK, b: '#B9E3FF' }); fill(g, 27, y - 3, 1, 4, WOOD) } },
97  { name: 'hammer', draw: (g, y, s) => { const t = s % 2 ? -2 : 0; fill(g, 27, y - 6 + t, 1, 8, WOOD); fill(g, 25, y - 8 + t, 5, 3, STEEL) } },
98  { name: 'guitar', draw: (g, y) => stamp(g, 24, y - 8, ['....w.', '....w.', '....w.', '...ww.', '..wwww', '.wwwww', '.wwwww', '..www.'], { w: WOOD }) },
99  { name: 'book', draw: (g, y) => { fill(g, 24, y - 4, 6, 5, SKY); fill(g, 25, y - 3, 4, 3, WHITE); fill(g, 27, y - 3, 1, 3, SKY) } },
100  { name: 'lollipop', draw: (g, y) => { stamp(g, 25, y - 9, ['.ppp.', 'pwpwp', 'pppwp', 'pwppp', '.ppp.'], { p: PINK, w: WHITE }); fill(g, 27, y - 4, 1, 5, WHITE) } },
101  { name: 'torch', draw: (g, y, s) => { fill(g, 26, y - 3, 2, 5, WOOD); fill(g, 25, y - 6, 4, 3, GOLD); fill(g, 26, y - 8 + (s % 2), 2, 2, RED) } },
102  { name: 'nothing', draw: () => undefined },
103]
104
105// ---- emotes beside the head (x 23..29, y 0..7) ------------------------------
106
107const EMOTE: Record<string, (g: Grid) => void> = {
108  '!': g => { fill(g, 27, 1, 2, 4, WHITE); fill(g, 27, 6, 2, 1, WHITE) },
109  '?': g => stamp(g, 25, 0, ['.www.', 'w...w', '....w', '...w.', '..w..', '.....', '..w..'], { w: WHITE }),
110  'z': g => stamp(g, 26, 2, ['www', '..w', '.w.', 'www'], { w: '#9A9A9A' }),
111  'zz': g => { stamp(g, 23, 4, ['www', '..w', '.w.', 'www'], { w: '#9A9A9A' }); stamp(g, 27, 0, ['www', '..w', '.w.', 'www'], { w: '#C0C0C0' }) },
112  'heart': g => stamp(g, 24, 1, ['.pp.pp.', 'ppppppp', 'ppppppp', '.ppppp.', '..ppp..', '...p...'], { p: PINK }),
113  'note': g => stamp(g, 25, 0, ['...w', '...w', '...w', '.www', 'wwww', '.ww.'], { w: WHITE }),
114  'ok': g => stamp(g, 24, 1, ['.....g', '....gg', 'g..gg.', 'gggg..', '.gg...'], { g: LEAF }),
115  'dots': g => { fill(g, 24, 4, 1, 1, WHITE); fill(g, 26, 4, 1, 1, WHITE); fill(g, 28, 4, 1, 1, WHITE) },
116  'pencil': g => stamp(g, 25, 1, ['....y', '...yy', '..yy.', '.yy..', 'kk...'], { y: GOLD, k: INK }),
117}
118
119export function draw(f: Frame): Grid {
120  const g: Grid = new Array(GRID_W * GRID_H).fill(undefined)
121  const bob = f.mood === 'dancing' && f.step % 2 === 1 ? 1 : 0
122  body(g, f)
123  HATS[f.hat % HATS.length]?.draw(g, bob)
124  const armY = 10 + bob + 4 + (f.mood === 'surprised' ? -4 : f.mood === 'dancing' && (f.step === 1 || f.step === 2) ? -3 : 0)
125  if (f.reaction !== 'note') THINGS[f.thing % THINGS.length]?.draw(g, armY, f.step)
126  const emote = f.reaction === 'love' ? 'heart' : f.reaction === 'ok' ? 'ok' : f.reaction === 'note' ? 'pencil' : f.reaction === 'chat' ? 'dots' : f.emote
127  if (emote) EMOTE[emote]?.(g)
128  return g
129}
130
131// ---- outputs ----------------------------------------------------------------
132
133const hex = (c: string): [number, number, number] => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)]
134
135function b64(bytes: Uint8Array): string {
136  const anyBytes = bytes as unknown as { toBase64?: () => string }
137  if (typeof anyBytes.toBase64 === 'function') return anyBytes.toBase64()
138  const T = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
139  let out = ''
140  for (let i = 0; i < bytes.length; i += 3) {
141    const a = bytes[i] ?? 0, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
142    const n = (a << 16) | (b << 8) | c
143    out += T[(n >> 18) & 63]! + T[(n >> 12) & 63]! + (i + 1 < bytes.length ? T[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? T[n & 63]! : '=')
144  }
145  return out
146}
147
148// The grid as RGBA pixels, each grid pixel `scale` wide and tall, transparent
149// where nothing is drawn; for a terminal `Image`.
150export function toRgba(g: Grid, scale = 4): { rgba: string; width: number; height: number } {
151  const width = GRID_W * scale, height = GRID_H * scale
152  const out = new Uint8Array(width * height * 4)
153  for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
154    const c = g[Math.floor(y / scale) * GRID_W + Math.floor(x / scale)]
155    if (!c) continue
156    const [r, gg, b] = hex(c)
157    const i = (y * width + x) * 4
158    out[i] = r; out[i + 1] = gg; out[i + 2] = b; out[i + 3] = 255
159  }
160  return { rgba: b64(out), width, height }
161}
162
163// The grid as half-block cells (two pixels per cell, `▀` top over `▄` bottom),
164// `step` grid pixels per cell column; for a terminal `Raster`.
165export const RASTER_COLS = GRID_W / 2
166export const RASTER_ROWS = GRID_H / 4
167const DEFAULT_BG = 0x01000000
168const rgb = (c: string | undefined): number => (c ? parseInt(c.slice(1), 16) : DEFAULT_BG)
169export function toRaster(g: Grid): string {
170  const words = new Uint32Array(RASTER_COLS * RASTER_ROWS * 3)
171  let k = 0
172  for (let row = 0; row < RASTER_ROWS; row++) for (let col = 0; col < RASTER_COLS; col++) {
173    const x = col * 2, yt = row * 4, yb = row * 4 + 2
174    const t = g[yt * GRID_W + x] ?? g[(yt + 1) * GRID_W + x] ?? g[yt * GRID_W + x + 1]
175    const b = g[yb * GRID_W + x] ?? g[(yb + 1) * GRID_W + x] ?? g[yb * GRID_W + x + 1]
176    if (t && b) { words[k++] = 0x2580; words[k++] = rgb(t); words[k++] = rgb(b) }
177    else if (t) { words[k++] = 0x2580; words[k++] = rgb(t); words[k++] = DEFAULT_BG }
178    else if (b) { words[k++] = 0x2584; words[k++] = rgb(b); words[k++] = DEFAULT_BG }
179    else { words[k++] = 0x20; words[k++] = DEFAULT_BG; words[k++] = DEFAULT_BG }
180  }
181  return b64(new Uint8Array(words.buffer))
182}
183
184// The same grid as plain text lines (for tests and previews): `#` body, `.` empty.
185export function toAscii(g: Grid): string[] {
186  const lines: string[] = []
187  for (let y = 0; y < GRID_H; y++) {
188    let s = ''
189    for (let x = 0; x < GRID_W; x++) { const c = g[y * GRID_W + x]; s += !c ? '.' : c === CLAY ? '#' : c === INK ? '@' : '+' }
190    lines.push(s)
191  }
192  return lines
193}
194
195// ---- picking the frame -------------------------------------------------------
196
197function hash(n: number): number {
198  let x = (n ^ 0x9e3779b9) >>> 0
199  x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
200  x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
201  return (x ^ (x >>> 16)) >>> 0
202}
203
204export type Scene = { open: number; isFresh: boolean; at: number; seed: number; reaction?: Reaction }
205
206// What Clawd looks like at time `at` (ms): the beat runs at 4 a second while
207// dancing, 1 a second asleep; hat and thing change with the seed and slowly
208// with time, so a long wait gets a wardrobe change.
209export function frameAt(s: Scene): Frame {
210  const mood: Mood = s.open === 0 ? 'asleep' : s.isFresh ? 'surprised' : 'dancing'
211  const beat = Math.floor(s.at / (mood === 'asleep' ? 1000 : 250))
212  const wardrobe = Math.floor(s.at / 45_000)
213  const hat = hash(s.seed * 5 + wardrobe) % HATS.length
214  const thing = hash(s.seed * 11 + 3 + Math.floor(s.at / 30_000)) % THINGS.length
215  const emotes = ['', '', '', 'note', '', '!', '', '?', '', 'heart', '', '']
216  const emote = mood === 'asleep' ? (beat % 2 ? 'zz' : 'z') : mood === 'surprised' ? (beat % 2 ? '!' : '') : emotes[hash(s.seed + Math.floor(s.at / 3000)) % emotes.length]!
217  const f: Frame = { mood, step: beat % 4, hat, thing, emote }
218  if (s.reaction) f.reaction = s.reaction
219  return f
220}
221
222// Two frames draw the same picture: nothing to blit.
223export const frameKey = (f: Frame): string => `${f.mood}:${f.step}:${f.hat}:${f.thing}:${f.emote}:${f.reaction ?? ''}`
224
hooks/items.ts 344 lines
1// Pure helpers for the Needs-you pane: items, the messages that go back into
2// the session, the reply catcher, and the little avatar. No engine in here, so
3// every function is testable on its own.
4
5import type { Item, Kind, PaneSettings } from '../types'
6
7export const KINDS: readonly Kind[] = ['decide', 'do', 'know']
8
9export const TOOL_PIN = 'mcp__needs-you__pin'
10export const TOOL_RESOLVE = 'mcp__needs-you__resolve'
11export const TOOL_UPDATE = 'mcp__needs-you__update'
12export const TOOL_LIST = 'mcp__needs-you__list'
13
14export const DEFAULT_SETTINGS: PaneSettings = { view: 'list', focus: true, catch: true, showDone: false }
15
16export type Pin = { title: string; detail: string; kind: Kind; options: string[] }
17
18// What the model sent to the pin tool, checked field by field: a plugin tool's
19// input arrives untyped.
20export function parsePin(args: Record<string, unknown>): { ok: true; pin: Pin } | { ok: false; why: string } {
21  const title = oneLine(String(args.title ?? '')).slice(0, 140)
22  if (!title) return { ok: false, why: 'title is required: one plain line saying what you need from the person' }
23  const kind = KINDS.includes(args.kind as Kind) ? (args.kind as Kind) : 'know'
24  const detail = String(args.detail ?? '').trim().slice(0, 600)
25  const options = Array.isArray(args.options)
26    ? args.options.map(o => oneLine(String(o)).slice(0, 60)).filter(Boolean).slice(0, 4)
27    : []
28  if (kind === 'decide' && options.length < 2) {
29    return { ok: false, why: 'a decision needs 2 to 4 options, your recommendation first' }
30  }
31  return { ok: true, pin: { title, detail, kind, options: kind === 'decide' ? options : [] } }
32}
33
34export function newItem(items: readonly Item[], pin: Pin, from: string, now: number, agentId?: string): Item {
35  const n = items.reduce((m, i) => Math.max(m, i.n), 0) + 1
36  const item: Item = {
37    id: `n${n}-${now.toString(36)}`,
38    n,
39    kind: pin.kind,
40    title: pin.title,
41    detail: pin.detail,
42    options: pin.options,
43    from,
44    status: 'open',
45    createdAt: now,
46    answer: '',
47    notes: [],
48  }
49  if (agentId) item.agentId = agentId
50  return item
51}
52
53export function findByN(items: readonly Item[], n: number): Item | undefined {
54  return items.find(i => i.n === n)
55}
56
57export function openItems(items: readonly Item[]): Item[] {
58  return items.filter(i => i.status !== 'done')
59}
60
61export function doneItems(items: readonly Item[]): Item[] {
62  return items.filter(i => i.status === 'done')
63}
64
65// A sent answer, note or chat request becomes seen once a model request has
66// carried it: a request of the loop it went to (`agentId`, absent for main)
67// that started at or after it was handed over. Ids in `holding` are still
68// being handed over, so no request has them for sure yet.
69export function markSeen(
70  items: readonly Item[],
71  turnId: string,
72  stepAt: number,
73  agentId?: string,
74  holding: ReadonlySet<string> = new Set(),
75): Item[] {
76  const carried = (at: number | undefined, to: string | undefined) => to === agentId && (at ?? 0) <= stepAt
77  return items.map(i => {
78    if (holding.has(i.id)) return i
79    let next = i
80    const notes = i.notes.map(n => (n.status === 'sent' && carried(n.at, n.to) ? { ...n, status: 'seen' as const } : n))
81    if (notes.some((n, k) => n !== i.notes[k])) next = { ...next, notes }
82    if (i.chatAt !== undefined && agentId === undefined && i.chatAt <= stepAt) {
83      next = { ...next }
84      delete next.chatAt
85    }
86    if (i.status === 'sent' && carried(i.sentAt, i.sentTo)) next = { ...next, status: 'seen', seenTurn: turnId, seenAge: 0 }
87    return next
88  })
89}
90
91// How many main turns a seen item stays in the open list unresolved.
92export const ARCHIVE_TURNS = 6
93
94// A seen item stays as one dim line ("Claude has it") until the model resolves
95// it. One left unresolved leaves after ARCHIVE_TURNS main turns; a quiet one
96// (an ack, the person's own reminder) after the turn that carried it. Nothing
97// leaves while a note or chat request on it is still on its way.
98export function archiveSeen(items: readonly Item[], endedTurn: string): Item[] {
99  return items.map(i => {
100    if (i.status !== 'seen' || i.seenTurn === endedTurn) return i
101    if (i.chatAt !== undefined || i.notes.some(n => n.status === 'sent')) return i
102    const age = (i.seenAge ?? 0) + 1
103    const limit = isQuietItem(i) ? 1 : ARCHIVE_TURNS
104    return age >= limit ? { ...i, status: 'done', seenAge: age } : { ...i, seenAge: age }
105  })
106}
107
108export type Answer =
109  | { kind: 'check' }
110  | { kind: 'option'; text: string }
111  | { kind: 'note'; text: string }
112  | { kind: 'chat' }
113
114// An item whose tick needs nothing from the model: a 'know' the person read,
115// or a reminder the person pinned for themselves (`/needs-you add`).
116export function isQuietItem(item: Item): boolean {
117  return item.from === 'you' || item.kind === 'know'
118}
119
120// An answer that is appended quietly and never starts a turn.
121export function isQuiet(item: Item, answer: Answer): boolean {
122  if (answer.kind === 'chat') return false
123  if (item.from === 'you') return true
124  return item.kind === 'know' && answer.kind === 'check'
125}
126
127// A tick on a decision is "go with your recommendation": the first option.
128export function normalize(item: Item, answer: Answer): Answer {
129  if (answer.kind === 'check' && item.kind === 'decide' && item.options[0]) return { kind: 'option', text: item.options[0] }
130  return answer
131}
132
133// What the main conversation still has to read: answers handed to it that no
134// request carried and that ask for a reply. A key of them, '' for none.
135export function pendingKey(items: readonly Item[]): string {
136  const keys: string[] = []
137  for (const i of items) {
138    if (i.status === 'done') continue
139    if (i.status === 'sent' && !i.sentTo && !isQuietItem(i)) keys.push(`${i.id}@${i.sentAt ?? 0}`)
140    if (i.from !== 'you') for (const n of i.notes) if (n.status === 'sent' && !n.to) keys.push(`${i.id}:${n.at}`)
141    if (i.chatAt !== undefined) keys.push(`${i.id}~${i.chatAt}`)
142  }
143  return keys.join(',')
144}
145
146// The hidden message the model reads when the person acts in the pane while a
147// turn runs (or, for a quiet one, at the next turn). The person never sees it.
148export function messageFor(item: Item, answer: Answer): string {
149  const head = `[needs-you] #${item.n} "${item.title}"`
150  const tail = `Act on this, then call ${TOOL_RESOLVE} with n=${item.n}. Do not ask about it again in prose.`
151  const quiet = 'No reply needed; it leaves the pane by itself.'
152  if (item.from === 'you' && answer.kind !== 'chat') {
153    const what = answer.kind === 'note' ? `the person added a note to their own reminder: "${answer.text}"` : 'the person ticked off their own reminder'
154    return `${head}: ${what}. ${quiet}`
155  }
156  switch (answer.kind) {
157    case 'check':
158      return item.kind === 'know'
159        ? `${head}: the person has read this. ${quiet}`
160        : `${head}: the person has done this. ${tail}`
161    case 'option': {
162      const rec = item.options[0] === answer.text ? ' (your recommendation)' : ''
163      return `${head}: the person chose "${answer.text}"${rec}. ${tail}`
164    }
165    case 'note':
166      return `${head}: the person added a note: "${answer.text}". Take it into account. If it changes the question, call ${TOOL_UPDATE} with n=${item.n}; call ${TOOL_RESOLVE} only once the item is settled.`
167    case 'chat':
168      return `${head}: the person wants to talk this one through. In your next reply, bring it up in plain words (what it is, why it matters, what you recommend), ask what they want, and wait. Leave the item open until it is settled.`
169  }
170}
171
172// The same answer as the person's own words, for a prompt that starts a turn:
173// the person sees it in the transcript, so it reads as them speaking. What to
174// do with it is in the system prompt section.
175export function personMessageFor(item: Item, answer: Answer): string {
176  const head = `[needs-you] #${item.n} "${item.title}"`
177  switch (answer.kind) {
178    case 'check':
179      return item.kind === 'know' ? `${head}: got it.` : `${head}: done.`
180    case 'option':
181      return item.options[0] === answer.text
182        ? `${head}: go with your recommendation, "${answer.text}".`
183        : `${head}: I choose "${answer.text}".`
184    case 'note':
185      return `${head}: a note from me: "${answer.text}"`
186    case 'chat':
187      return `${head}: can we talk this one through? What is it, why does it matter, and what do you recommend?`
188  }
189}
190
191// The main conversation's copy of an answer that went to a helper agent.
192export function fyiFor(item: Item, answer: Answer): string {
193  const what = answer.kind === 'note' ? `added a note: "${answer.text}"` : answer.kind === 'option' ? `chose "${answer.text}"` : 'ticked it'
194  return `[needs-you] FYI: #${item.n} "${item.title}" was asked by ${item.from || 'a helper agent'}. The person ${what}; it went to that agent. No action needed from you.`
195}
196
197// The prompt that starts a turn when answers reached the conversation after its
198// last request: the rows are in the conversation; nothing has read them.
199export const WAKE_TEXT =
200  '[needs-you] The person answered in the Needs-you pane while you were finishing your last reply. Read the [needs-you] messages just above and act on them.'
201
202// The hidden row after the reply catcher pinned asks from the last reply.
203export function caughtNote(pinned: readonly Item[]): string {
204  const list = pinned.map(i => `#${i.n} "${i.title}"`).join(', ')
205  return `[needs-you] From your last reply, the pane pinned ${list} for the person. They are open items now: call ${TOOL_RESOLVE} on each once handled, and do not ask them again in prose.`
206}
207
208export function pinReply(item: Item, isPlaced = true): string {
209  const base = `Pinned #${item.n} (${item.kind}): "${item.title}".`
210  const close = `Call ${TOOL_RESOLVE} n=${item.n} once it is handled.`
211  return isPlaced
212    ? `${base} The person sees it in the Needs-you pane; in your reply, at most one short line pointing at #${item.n}. ${close}`
213    : `${base} The Needs-you pane is not showing on the person's screen, so also ask it in one plain line in your reply and say they can type /needs-you to open the pane. ${close}`
214}
215
216export function listReply(items: readonly Item[]): string {
217  const open = openItems(items)
218  if (open.length === 0) return 'Nothing is pinned.'
219  return open
220    .map(i => {
221      const state = i.status === 'open' ? 'waiting' : i.status
222      const ans = i.answer ? ` answer: "${i.answer}"` : ''
223      const notes = i.notes.length ? ` notes: ${i.notes.map(n => `"${n.text}"`).join('; ')}` : ''
224      return `#${i.n} [${i.kind}, ${state}] ${i.title}${ans}${notes}`
225    })
226    .join('\n')
227}
228
229// ---- the reply catcher: asks that never got pinned -----------------------
230
231const CUE = /\?\s*$|\?\n|let me know|your call|up to you|do you want|would you like|should i\b|want me to|need you to|you(?:'ll| will) need to|please (?:confirm|check|send|add|create|mint|set)|which (?:one|option|do you)|heads.?up|fyi\b|note that you|before i (?:can|continue)|waiting on you|blocked on/i
232
233export function hasCue(answer: string): boolean {
234  return answer.length > 20 && CUE.test(answer)
235}
236
237export const CATCH_SYSTEM = `You read the last reply of a coding assistant and pull out only the things the PERSON must now decide, do outside the chat, or know because it blocks or changes the work. Ignore things the assistant itself will do next, status updates, summaries, and rhetorical questions. Answer with a JSON array and nothing else. Each element: {"kind":"decide"|"do"|"know","title":"one plain line, no code names or jargon, under 100 characters","detail":"one or two plain sentences, may be empty","options":["only for decide: 2-4 short options, recommendation first"]}. Empty array when nothing needs the person.`
238
239export function catchPrompt(answer: string): string {
240  return `The reply:\n\n${answer.slice(0, 6000)}`
241}
242
243export function parseCaught(text: string): Pin[] {
244  const start = text.indexOf('[')
245  const end = text.lastIndexOf(']')
246  if (start < 0 || end <= start) return []
247  let raw: unknown
248  try {
249    raw = JSON.parse(text.slice(start, end + 1))
250  } catch {
251    return []
252  }
253  if (!Array.isArray(raw)) return []
254  const pins: Pin[] = []
255  for (const row of raw.slice(0, 4)) {
256    if (!row || typeof row !== 'object') continue
257    const parsed = parsePin(row as Record<string, unknown>)
258    if (parsed.ok) pins.push(parsed.pin)
259  }
260  return pins
261}
262
263// ---- text helpers --------------------------------------------------------
264
265export function oneLine(text: string): string {
266  return text.replace(/\s+/g, ' ').trim()
267}
268
269export function firstLine(text: string, max: number): string {
270  const line = text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
271  const plain = line.replace(/^#+\s*/, '').replace(/[*_`]/g, '')
272  return plain.length > max ? `${plain.slice(0, Math.max(1, max - 1))}…` : plain
273}
274
275// A label cut to `max` columns with an ellipsis.
276export function clip(text: string, max: number): string {
277  return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text
278}
279
280export function parseCommand(args: string): { verb: string; rest: string } {
281  const trimmed = args.trim()
282  const space = trimmed.indexOf(' ')
283  if (space < 0) return { verb: trimmed.toLowerCase(), rest: '' }
284  return { verb: trimmed.slice(0, space).toLowerCase(), rest: trimmed.slice(space + 1).trim() }
285}
286
287// How tall the pane wants to be while it sits inline above the prompt.
288export function wantedRows(items: readonly Item[], settings: PaneSettings): number {
289  if (settings.view === 'icon') return 3
290  const open = openItems(items)
291  let rows = 2
292  for (const i of open) {
293    // An answered item is one line; an open one is a full card: title, detail,
294    // a line of chips, its notes, the note field, and a gap.
295    if (i.status !== 'open') {
296      rows += 2
297      continue
298    }
299    rows += 4 + Math.ceil(i.title.length / 36) + Math.ceil(i.detail.length / 40) + i.notes.length
300  }
301  if (doneItems(items).length) rows += 1 + (settings.showDone ? doneItems(items).length : 0)
302  return Math.min(40, Math.max(3, rows))
303}
304
305// ---- the text face ---------------------------------------------------------
306// Surfaces that draw no pictures (desktop, mobile) get a one-line face instead
307// of the pixel mascot in clawd.ts.
308
309const EMOTE_SLEEP = ['z', 'zz', 'zzz']
310
311export type AvatarState = { open: number; isFresh: boolean; tick: number; seed: number }
312
313function hash(n: number): number {
314  let x = (n ^ 0x9e3779b9) >>> 0
315  x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
316  x = Math.imul(x ^ (x >>> 16), 0x45d9f3b) >>> 0
317  return (x ^ (x >>> 16)) >>> 0
318}
319
320function pick<T>(list: readonly T[], n: number): T {
321  return list[hash(n) % list.length] as T
322}
323
324// What the mascot is doing this tick.
325export function avatarMood(s: AvatarState): 'asleep' | 'surprised' | 'dancing' {
326  return s.open === 0 ? 'asleep' : s.isFresh ? 'surprised' : 'dancing'
327}
328
329// The face line's fixed width: ASCII only, so every terminal and font agrees
330// (‿ • ⊙ draw one or two columns depending on the font).
331export const AVATAR_LINE_WIDTH = 9
332
333// One line, for the mobile list and tight widths: the face only, padded so the
334// text after it never shifts between frames.
335export function avatarLine(s: AvatarState): string {
336  const mood = avatarMood(s)
337  const face =
338    mood === 'asleep' ? `[-_-] ${pick(EMOTE_SLEEP, Math.floor(s.tick / 2))}` :
339    mood === 'surprised' ? (s.tick % 2 ? '[O_O]!' : '[O_O]') :
340    s.tick % 7 === 0 ? '[^_-]' : '[^_^]'
341  return face.padEnd(AVATAR_LINE_WIDTH, ' ')
342}
343
344
types/index.d.ts 67 lines
1// What one pinned item is, and what the pane keeps in the session.
2
3export type Kind = 'decide' | 'do' | 'know'
4
5// open: waiting on the person
6// sent: the person answered; the message is in the session, no request has carried it yet
7// seen: a model request carried it; Claude has it but has not resolved it
8// done: the model resolved it, or it sat seen for several turns
9export type Status = 'open' | 'sent' | 'seen' | 'done'
10
11// `at`: when the note was handed over; `to`: the helper agent that got it
12// (absent: the main conversation).
13export type Note = { text: string; at: number; status: 'sent' | 'seen'; to?: string }
14
15export type Item = {
16  id: string
17  n: number
18  kind: Kind
19  title: string
20  detail: string
21  options: string[]
22  from: string
23  agentId?: string
24  status: Status
25  createdAt: number
26  answer: string
27  notes: Note[]
28  // Left from the old expand/collapse pane; no longer read or written.
29  isExpanded?: boolean
30  // When the answer was handed over, and the helper agent that got it.
31  sentAt?: number
32  sentTo?: string
33  // A "chat about this" no model request has carried yet.
34  chatAt?: number
35  seenTurn?: string
36  // Main turns ended since it was seen; it is archived after a few.
37  seenAge?: number
38}
39
40export type View = 'icon' | 'list'
41
42export type PaneSettings = {
43  view: View
44  focus: boolean
45  catch: boolean
46  showDone: boolean
47}
48
49export type Live = {
50  isTurnRunning: boolean
51  turnId: string
52  pinsThisTurn: number
53  lastPinAt: number
54  tick: number
55}
56
57declare module 'claude-code' {
58  interface PluginState {
59    'needs-you': {
60      items: Shaped<Item[]>
61      settings: Shaped<PaneSettings>
62      live: Live
63      interim: string[]
64    }
65  }
66}
67