SLOPSHOPPER

multitask

Multitasking: the main session becomes the talking mind and hands all work to background thoughts it watches and steers

newpanebandrowsguardcommand
v0.3.0no licenseupdated 2026-10-10othrayte/claude-multitask/multitask
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · multitask
│ ┃ Thoughts ✕ › fix the failing auth test and add an audit log call │ ┃ Multitask is on · No thoughts yet. │ ┃ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /multitask │ ⎿ multitask: Multitask is on: this session is now the voice and ha │ │ Multitask ▾ [ thoughts ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Multitask ▾ [ thoughts ]
Pane · Thoughts
Multitask is on · No thoughts yet.
README

claude-multitask

Talk to Claude while it works, keep every topic in its own thread, and get a quick emoji when a reply would be overkill. Three Claude Code mods: multitask, thread-chat and react.

Demo: replies sorted into colour-coded threads, the thread view filtered to one thread, a background task running as a dot while the chat carries on, and an emoji reaction

Why

  • Talk while it works. With multitask you talk to one voice, and its thoughts (background agents) do the work. The voice is always free to talk.
  • No waiting, nothing held back. Ask something new, add a detail or change your mind whenever it occurs to you. The voice passes it to the right piece of work.
  • Questions come straight away. Your message isn't queued behind work in progress, so clarifying questions come at once and the right work starts sooner.
  • Topics stay apart. With thread-chat, a # heading puts a message in its topic's thread: colour-coded, readable on its own and easy to find later.
  • Emoji reactions. With react, the agent can acknowledge a message with an emoji reaction instead of a reply.

Examples: docs/why.md.

Install

Requires Claude Code 2.1.290 or later. Made for the Code tab in the Claude desktop app.

claude plugin marketplace add othrayte/claude-multitask
claude plugin install multitask@claude-multitask
claude plugin install thread-chat@claude-multitask
claude plugin install react@claude-multitask

Install any or all of them.

Use

In the band above the prompt, Multitask has a dropdown, and thread-chat a thread picker followed by its threads menu, a cog (⚙), with their switches for the chat. The mobile app shows only the thread picker: use the commands there. React has no control in the band: /react is its switch.

Multitask

  • /multitask turns Multitask on in a chat (run it again to turn it off). The chat becomes the voice and hands all work to thoughts.
  • Multitask's dropdown reads Multitask while it is on and Focus while it is off; pick the other to switch.
  • Left of the dropdown, a dot in its colour for each running thought pulses at each of its notes and fades out as it ends, as a check, or a cross if it failed. In the desktop app, hover a dot for the thought's name, latest note and its age.
  • /thoughts, or the thoughts button above the prompt, opens the Thoughts pane: what each thought is doing and how long since its last note, its report, tell to send it a message yourself, and stop. It lists the 16 most recently ended thoughts (N more shows the rest). The thoughts are per chat, and kept when the chat is restarted.
  • The voiceEffort setting (Voice effort, in /config) sets how hard the voice thinks: low (default), medium, high, or session to match the session. Thoughts keep the session's setting.

Threads

  • /threads, or the On in this chat switch in the threads menu (the cog), turns threads on in a chat (again to turn them off). The menu opens while the pointer is on the cog or it has the focus in the desktop app, and with a press on it in the terminal.
  • A # name heading in a message starts a thread, or replies to it if it exists. Close or abbreviated names find the existing thread. The agent replies under the same headings.
  • #+name starts a new thread even when an existing one is close.
  • To put a message in a thread, hover it for + thread (desktop app); to move it to another, press ▾ beside its thread tag.
  • The thread picker above the prompt, a dropdown named for the picked thread and tinted in its colour, picks the thread messages without a heading go to, or No thread. A heading still wins, and moves the picker to its thread.
  • New thread in the threads menu starts a thread by name and picks it; Manage threads there merges, archives or deletes threads.
  • Archived threads leave the pickers but keep their messages; Manage threads brings them back, as does a heading naming one. Deleting a thread takes its tag off its messages.
  • Filter the chat, in the threads menu while threads are on, is off at first, so the chat shows every message. On, with a thread picked, the chat shows only that thread's messages, and those since your last message that are in no thread. It is per chat, and kept when the chat is restarted.
  • /thread-view, or Open thread view in the threads menu, opens the thread view: a pane with a switch for each thread below its newest message. With none on it shows every message; with some on, only those threads' messages, and those since your last message that are in no thread, as Filter the chat does. The thread picker above the prompt and Filter the chat do not change it.
  • /merge-threads <thread> into <thread> also merges two threads.
  • Agent may start threads, in the threads menu while threads are on, lets the agent start threads itself. It is per chat, and kept when the chat is restarted.

Reactions

  • Reactions are on in a new chat. /react turns them off in that chat (again to turn them back on). The choice is per chat, and kept when the chat is restarted.
  • /react default switches whether reactions are on by default: in new chats, and in chats where you have not used /react. A chat's own /react choice wins over the default. The default applies to every chat and is kept across restarts.
  • While they are on, a reply of a single emoji shows as a reaction on your message, not as a reply. With thread-chat, reactions show in its thread view too.
  • On the desktop the reaction sits on your message's bottom-right corner. /react place under moves it under the message, where its time and copy buttons show; /react place corner moves it back. The choice applies to every chat.
Source 5 files
hooks/register.tsx 1538 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  AgentStatus,
4  EngineInterface,
5  Register,
6  RenderElement,
7  SessionSendAddress,
8  SessionSendResult,
9  Timer,
10  TurnCompleteReason,
11} from 'claude-code'
12
13import type { KeptThoughts, Thought, ThoughtStatus, Update } from '../types'
14import type { HookStream, TurnStepChunk, TurnStepResult } from 'claude-code'
15import {
16  NO_RESPONSE,
17  QUIET,
18  RELAY_NOTES,
19  THOUGHT,
20  VOICE,
21  VOICE_OFF,
22  VOICE_REMINDER,
23  WAKE,
24  describeThoughts,
25  formatNews,
26  formatUpdates,
27  fromPerson,
28  heardOf,
29  isQuiet,
30  isRelayText,
31  isWakeText,
32  marksOf,
33  newsOf,
34  oneLine,
35  pendingNote,
36  relayNames,
37} from './prompts'
38import { TELL_FIELD, drawThoughts } from './pane'
39import { FADE_MS, drawDots } from './band'
40
41const isOn = atom({ plugin: 'multitask', key: 'isOn' } as const, false)
42const thoughts = atom({ plugin: 'multitask', key: 'thoughts' } as const, [])
43const inbox = atom({ plugin: 'multitask', key: 'inbox' } as const, [])
44const isMainBusy = atom({ plugin: 'multitask', key: 'isMainBusy' } as const, false)
45const toldVoice = atom({ plugin: 'multitask', key: 'toldVoice' } as const, false)
46const expanded = atom({ plugin: 'multitask', key: 'expanded' } as const, [])
47const openReports = atom({ plugin: 'multitask', key: 'openReports' } as const, [])
48const heard = atom({ plugin: 'multitask', key: 'heard' } as const, {})
49const paneTick = atom({ plugin: 'multitask', key: 'paneTick' } as const, 0)
50const bandTick = atom({ plugin: 'multitask', key: 'bandTick' } as const, 0)
51const tellingTo = atom({ plugin: 'multitask', key: 'tellingTo' } as const, '')
52const colorTurn = atom({ plugin: 'multitask', key: 'colorTurn' } as const, 0)
53const showsAllEnded = atom({ plugin: 'multitask', key: 'showsAllEnded' } as const, false)
54
55const PANE = 'multitask-thoughts'
56// Multitask's dropdown in the band. Its face is the mode the chat is in, Multitask or
57// Focus (Multitask off); picking the other mode switches to it.
58const DROPDOWN = 'multitask'
59const MULTITASK_MODE = 'multitask'
60const FOCUS_MODE = 'focus'
61const AGENT = 'multitask:thought'
62const TOOL = {
63  think: 'mcp__multitask__think',
64  tell: 'mcp__multitask__tell',
65  unqueue: 'mcp__multitask__unqueue',
66  stop: 'mcp__multitask__stop',
67  thoughts: 'mcp__multitask__thoughts',
68} as const
69const OWN_TOOLS: ReadonlySet<string> = new Set(Object.values(TOOL))
70
71// The ways a person's own prompt arrives: typed in the terminal, the desktop
72// app (an SDK host) or Remote Control.
73const PERSON = new Set(['composer', 'sdk', 'bridge'])
74
75// How hard a model request may be asked to think, from least to most.
76const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
77
78// What the voice may still call itself while multitasking is on.
79const VOICE_TOOLS = /^(mcp__multitask__|mcp__thread-chat__)|^(AskUserQuestion|ToolSearch)$/
80
81// The colours thoughts are drawn in: the first ones, the clearest, taken in turn; the rest
82// only while every one of those is held (see freeColor).
83const FIRST_COLORS = ['#4fa3e0', '#d9636a', '#58b36b', '#c98a2e']
84const PALETTE = [...FIRST_COLORS, '#9b6fd6', '#2fb3a6', '#d66bb0', '#8a9a3a']
85
86// Updates arriving close together reach the voice as one message.
87const FLUSH_MS = 2000
88// How long an interrupted thought gets to end its turn before the message goes anyway.
89const INTERRUPT_GRACE_MS = 4000
90// How often, and how many times, a hand-over looks for the engine to have ended a thought's run.
91const SETTLE_POLL_MS = 250
92const SETTLE_POLLS = 20
93// How long after a thought's turn ended its run may still count as running, ending, before
94// what waits for it goes anyway.
95const SETTLE_MS = SETTLE_POLL_MS * SETTLE_POLLS
96// When the voice is woken to hand a thought what waits for it, should no hook of this
97// mod's have handed it over since it fell due: after each of these waits, then no more.
98const DUE_WAKES_MS = [3000, 15000, 60000]
99// How many of a thought's progress notes are kept, and how long each may be.
100const NOTES_KEPT = 5
101const NOTE_MAX = 500
102// How long the engine's notice that a thought's turn ended waits, at most, for that end
103// to be settled.
104const NOTICE_WAIT_MS = 1000
105// How long after a change to the thoughts they are kept in the store, changes meanwhile
106// going with it. A progress note alone keeps nothing: it goes with the next change, or as
107// the session ends.
108const KEEP_MS = 2000
109// What of a session's thoughts the store keeps: those running, and of the rest the most
110// recently ended, to this many in all and this many characters of JSON; each one's task and
111// report cut to this length.
112const THOUGHTS_KEPT = 200
113const THOUGHTS_KEPT_CHARS = 256 * 1024
114const TEXT_KEPT = 4000
115// Of the sessions whose thoughts the store keeps, those kept last, to this many and this
116// many characters of JSON in all; the rest are dropped as a session starts. The store
117// holds 4 MiB at most, the switches of every session included.
118const SESSIONS_KEPT = 20
119const SESSIONS_KEPT_CHARS = 2 * 1024 * 1024
120// What marks the engine's own hand-back of an agent's final report to the session that
121// started it ("Another Claude session sent a message: ... [Subagent hand-back] ...").
122const HANDBACK = '[Subagent hand-back]'
123
124const OFF = {
125  deny: 'Multitask is off. The user turns it on with the switch above the prompt or /multitask.',
126}
127const NOT_VOICE = { deny: 'Only the voice starts, steers or lists thoughts; this call came from another agent.' }
128
129let flushTimer: Timer | undefined
130// What keeps the thoughts in the store KEEP_MS after a change, while one is pending.
131let keepTimer: Timer | undefined
132// What draws the Thoughts pane's ages afresh, AGE_TICK_MS after it was last drawn.
133let ageTick: Timer | undefined
134const AGE_TICK_MS = 60_000
135// What draws the band afresh when its dots next change (band.tsx), and when each running
136// thought's turn was last seen to end or it was stopped, by thought id, for its dot to
137// fade out by: kept here, as `settling` is, and set before the change it goes with.
138let bandTimer: Timer | undefined
139const endings = new Map<string, number>()
140// The settling of each thought's latest turn end, by thought id, with its name, which the
141// engine's notice or hand-back of that end waits for. Kept here, not in state: what one
142// dispatch reads of state stands still while it runs, so a wait for it would never see it.
143const settling = new Map<string, { name: string; done: Promise<void> }>()
144// Of the messages due to each thought, by thought id, kept here for the same reason: the
145// hand-overs in flight, when each thought's fell due, the refusal the voice was last told
146// of, and the timer that wakes the voice to hand them over.
147const handingOver = new Set<string>()
148const dueAt = new Map<string, number>()
149const toldRefusal = new Map<string, string>()
150const dueWakes = new Map<string, Timer>()
151
152// Whether an engine's notice or hand-back is about this thought: the notice names its
153// agent id, the hand-back its sender (by id or, once named, by name).
154const isAbout = (text: string, id: string, name: string) => text.includes(id) || text.includes(`from="${name}"`)
155
156// How tell delivers a message, given as `how` (or `mode`).
157const TELL_HOWS = ['post', 'queue', 'interrupt']
158
159const TOOL_SPECS = [
160  {
161    name: 'think',
162    description:
163      'Start a thought: a background agent that does the work on one task while you keep talking with the user. Give it a self-contained brief; it cannot see this conversation.',
164    inputSchema: {
165      type: 'object',
166      properties: {
167        name: {
168          type: 'string',
169          description:
170            'A short kebab-case name for the thought, 1-3 words, by its task (fix-login-expiry); one already taken gets -2, -3',
171        },
172        task: { type: 'string', description: 'The full, self-contained brief' },
173      },
174      required: ['name', 'task'],
175    },
176  },
177  {
178    name: 'tell',
179    description:
180      'Give the thought `name` the text `message`, delivered as `how`. post (default): it reads it at its next step. queue: held until it finishes its current turn (can be unqueued until then). interrupt: cut what it is doing and hand it this now.',
181    inputSchema: {
182      type: 'object',
183      properties: {
184        name: { type: 'string', description: 'The thought\'s name' },
185        message: { type: 'string' },
186        how: { type: 'string', enum: TELL_HOWS, description: 'post, queue or interrupt; default post' },
187      },
188      required: ['name', 'message'],
189    },
190  },
191  {
192    name: 'unqueue',
193    description: 'Withdraw messages queued for a thought before it reads them.',
194    inputSchema: {
195      type: 'object',
196      properties: {
197        name: { type: 'string', description: 'The thought\'s name' },
198        index: { type: 'integer', description: '1-based position in its queue; leave out to withdraw all' },
199      },
200      required: ['name'],
201    },
202  },
203  {
204    name: 'stop',
205    description: 'Stop a thought for good.',
206    inputSchema: {
207      type: 'object',
208      properties: { name: { type: 'string', description: 'The thought\'s name' } },
209      required: ['name'],
210    },
211  },
212  {
213    name: 'thoughts',
214    description:
215      'Every thought\'s status, queue, latest progress notes and last report; with a thought\'s name, its recent transcript instead.',
216    inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'The thought\'s name' } } },
217  },
218]
219
220function str(e: unknown, key: string): string {
221  const value = (e as Record<string, unknown>)[key]
222  return typeof value === 'string' ? value.trim() : ''
223}
224
225// The thought a call names: every tool takes its name as `name`, as think gives it; an
226// earlier spelling, `thought`, still works.
227function refOf(e: unknown): string {
228  return str(e, 'name') || str(e, 'thought')
229}
230
231function findThought(list: Thought[], ref: string): Thought | undefined {
232  const wanted = ref.toLowerCase()
233  return list.find(t => t.id === ref) ?? list.find(t => t.name.toLowerCase() === wanted)
234}
235
236function unknownThought(list: Thought[], ref: string) {
237  const names = list.map(t => t.name).join(', ') || 'none'
238  if (ref === '') return { deny: `Give the thought's name as \`name\`. Thoughts: ${names}.` }
239  return { deny: `No thought called "${ref}". Thoughts: ${names}.` }
240}
241
242// What tell answers: what became of the message, or, as an error, why it did not arrive.
243type TellAnswer = { result: string } | { deny: string }
244
245function notDelivered(t: Thought, reason: string): TellAnswer {
246  return { deny: `${t.name} did not get the message: ${reason}` }
247}
248
249// A thought's name: up to three words of the text in lower-case kebab-case, of plain
250// letters and digits alone, as an agent's name is spelled; "" when it has none.
251function slug(text: string): string {
252  const words = text
253    .normalize('NFKD')
254    .replace(/[̀-ͯ]/g, '')
255    .toLowerCase()
256    .split(/[^a-z0-9]+/)
257    .filter(word => word !== '')
258    .slice(0, 3)
259  return words.join('-').slice(0, 40).replace(/-+$/, '')
260}
261
262function uniqueName(list: Thought[], name: string): string {
263  const taken = new Set(list.map(t => t.name))
264  let candidate = name
265  for (let n = 2; taken.has(candidate); n++) candidate = `${name}-${n}`
266  return candidate
267}
268
269// The colour a thought takes as it starts, or resumes when its own is taken, and where the
270// turn through the first colours stands after it. The first colours go in turn from `turn`,
271// past any another running thought holds, and the turn moves on past the one taken, so
272// thoughts started one after another go through them all; with every first colour held,
273// the first of the rest no running thought holds, the turn left as it is; with every one
274// held, the one fewest hold. An ended thought holds none, so a new one may take its colour.
275function freeColor(list: readonly Thought[], turn: number, except?: string): { color: string; turn: number } {
276  const held = list.filter(t => t.status === 'running' && t.id !== except).map(t => t.color)
277  const next = [...FIRST_COLORS.slice(turn), ...FIRST_COLORS.slice(0, turn)].find(color => !held.includes(color))
278  if (next !== undefined) return { color: next, turn: (FIRST_COLORS.indexOf(next) + 1) % FIRST_COLORS.length }
279  const holders = (color: string) => held.filter(one => one === color).length
280  return { color: PALETTE.reduce((best, color) => (holders(color) < holders(best) ? color : best)), turn }
281}
282
283// Picks a colour for a thought in `list` through freeColor, moving the turn on.
284type PickColor = (list: readonly Thought[], except?: string) => string
285
286// A thought set running in `list`: one running already is left as it is, so its rows keep
287// their colour while it runs; one resuming keeps its colour unless a running thought now
288// holds it, then takes a free one.
289function asRunning(list: readonly Thought[], t: Thought, pick: PickColor): Thought {
290  if (t.status === 'running') return t
291  const isTaken = list.some(one => one.id !== t.id && one.status === 'running' && one.color === t.color)
292  return { ...t, status: 'running', color: isTaken ? pick(list, t.id) : t.color }
293}
294
295function statusOf(status: AgentStatus): ThoughtStatus {
296  if (status === 'failed') return 'failed'
297  if (status === 'killed') return 'stopped'
298  if (status === 'completed' || status === 'idle') return 'idle'
299  return 'running'
300}
301
302function textOf(content: readonly { type: string; [field: string]: unknown }[]): string {
303  return content
304    .filter(block => block.type === 'text' && typeof block.text === 'string')
305    .map(block => block.text as string)
306    .join('\n')
307    .trim()
308}
309
310// The time now, for the Thoughts pane's ages; none where the clock cannot be read, which
311// leaves them out and fails nothing else.
312async function timeNow($: EngineInterface): Promise<number | undefined> {
313  try {
314    return await $.clock.now()
315  } catch {
316    return undefined
317  }
318}
319
320// Updates the thoughts through `fn`, which picks any colour it needs with `pick`. Where the
321// turn through the first colours then stands is kept in state, so a reload carries it on.
322async function updateThoughts($: EngineInterface, fn: (list: readonly Thought[], pick: PickColor) => Thought[]) {
323  const start = await read($, colorTurn)
324  let turn = start
325  await update($, thoughts, list => {
326    // Run again from the start should the write miss.
327    turn = start
328    return fn(list, (all, except) => {
329      const picked = freeColor(all, turn, except)
330      turn = picked.turn
331      return picked.color
332    })
333  })
334  if (turn !== start) await update($, colorTurn, () => turn)
335  scheduleKeep($)
336}
337
338async function patchThought($: EngineInterface, id: string, fn: (t: Thought) => Thought) {
339  await update($, thoughts, list => list.map(t => (t.id === id ? fn(t) : t)))
340  scheduleKeep($)
341}
342
343// Patches a thought as patchThought does, for a change that may pick it a colour.
344async function patchPicking($: EngineInterface, id: string, fn: (t: Thought, list: readonly Thought[], pick: PickColor) => Thought) {
345  await updateThoughts($, (list, pick) => list.map(t => (t.id === id ? fn(t, list, pick) : t)))
346}
347
348// Opens a thought in the Thoughts pane to show everything, or closes it: a finished
349// thought's one line becomes its whole card, a running card shows its whole task.
350async function toggleExpanded($: EngineInterface, id: string) {
351  await update($, expanded, ids => (ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id]))
352}
353
354// Opens a thought's last report in the Thoughts pane to read whole, or folds it to its first line.
355async function toggleReport($: EngineInterface, id: string) {
356  await update($, openReports, ids => (ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id]))
357}
358
359// Opens the field in a thought's card in the Thoughts pane to tell it something, or closes it.
360async function toggleTell($: EngineInterface, id: string) {
361  await update($, tellingTo, was => (was === id ? '' : id))
362}
363
364// The voice's tools are the model's from Multitask first coming on: a session that
365// never switches it on has no tool of this mod's. Registered again, each is replaced.
366async function offerVoiceTools($: EngineInterface) {
367  for (const spec of TOOL_SPECS) {
368    try {
369      await $.tool.register(spec)
370    } catch {
371      // the switch stands; the next time it comes on tries again
372    }
373  }
374}
375
376async function toggle($: EngineInterface): Promise<boolean> {
377  let now = false
378  await update($, isOn, was => (now = !was))
379  if (now) {
380    await offerVoiceTools($)
381    // What fell due for a thought while off goes to it now that it can be resumed again.
382    for (const t of await read($, thoughts)) if (isDue(t)) scheduleDueWake($, t.id, 0)
383  }
384  await keepSwitch($)
385  return now
386}
387
388// Switches to the mode picked in the band's dropdown; the mode the chat is in already,
389// or anything else, leaves it be.
390async function pickMode($: EngineInterface, value: string) {
391  const wantsOn = value === MULTITASK_MODE ? true : value === FOCUS_MODE ? false : undefined
392  if (wantsOn !== undefined && wantsOn !== (await read($, isOn))) await toggle($)
393}
394
395// Where a session's switch is kept between runs of the app: whether it is on, when it
396// was kept, and, once it has been on, the marks of its newest messages.
397const switchKey = (sessionId: string) => `session:${sessionId}`
398type KeptSwitch = { isOn: boolean; at: number; marks: string[] }
399const MARKS_KEPT = 200
400
401// Keeps the switch for the session's next run. A session never on keeps no marks: one
402// rewound or forked from it starts off as it would anyway. Best effort: a store or
403// transcript that cannot be read or written keeps what it kept.
404async function keepSwitch($: EngineInterface) {
405  try {
406    const key = switchKey(await $.session.id())
407    const was = (await $.store.get(key)) as Partial<KeptSwitch> | undefined
408    const now = await read($, isOn)
409    const isMarked = now || (was?.marks?.length ?? 0) > 0
410    const marks = isMarked ? marksOf(await $.session.messages()).slice(-MARKS_KEPT) : []
411    await $.store.set(key, { isOn: now, at: await $.clock.now(), marks } satisfies KeptSwitch)
412  } catch {
413    // kept as it was
414  }
415}
416
417// The session whose kept switch and thoughts a session takes up: its own, once it has kept
418// its switch; for one rewound or forked, which starts under a new id with copies of the
419// messages it kept, the session it came from: the kept one that knows the most of its
420// messages, else, of those near that, the one kept last (the one it was just in). None
421// when no session is known.
422async function keptSession($: EngineInterface): Promise<string | undefined> {
423  const own = await $.session.id()
424  if ((await $.store.get(switchKey(own))) !== undefined) return own
425  const mine = new Set(marksOf(await $.session.messages()))
426  if (mine.size === 0) return undefined
427  const scored: { id: string; at: number; score: number }[] = []
428  for (const key of await $.store.keys()) {
429    if (!key.startsWith('session:')) continue
430    const kept = ((await $.store.get(key)) ?? {}) as Partial<KeptSwitch>
431    const score = new Set((kept.marks ?? []).filter(mark => mine.has(mark))).size
432    if (score > 0) scored.push({ id: key.slice('session:'.length), at: kept.at ?? 0, score })
433  }
434  const best = Math.max(0, ...scored.map(s => s.score))
435  if (best < 3) return undefined
436  return scored.filter(s => s.score * 2 > best).sort((a, b) => b.at - a.at)[0]?.id
437}
438
439// Whether the switch the session `from` kept is on.
440async function keptSwitch($: EngineInterface, from: string): Promise<boolean> {
441  return ((await $.store.get(switchKey(from))) as Partial<KeptSwitch> | undefined)?.isOn === true
442}
443
444// Where a session's thoughts are kept between runs of the app (KeptThoughts).
445const thoughtsKey = (sessionId: string) => `thoughts:${sessionId}`
446
447// The thoughts as the store keeps them: each one's task and report cut to TEXT_KEPT, and,
448// past THOUGHTS_KEPT of them or THOUGHTS_KEPT_CHARS of JSON, those that ended longest ago
449// dropped; running ones are never dropped.
450function keptList(list: readonly Thought[]): Thought[] {
451  const all = list.map((t, i) => {
452    const kept = { ...t, task: t.task.slice(0, TEXT_KEPT), lastText: t.lastText.slice(0, TEXT_KEPT) }
453    return { t: kept, i, chars: JSON.stringify(kept).length + 1 }
454  })
455  let count = all.length
456  let chars = all.reduce((n, one) => n + one.chars, 0)
457  const oldestFirst = all
458    .filter(one => one.t.status !== 'running')
459    .sort((a, b) => (a.t.endedAt ?? 0) - (b.t.endedAt ?? 0) || a.i - b.i)
460  const dropped = new Set<number>()
461  for (const one of oldestFirst) {
462    if (count <= THOUGHTS_KEPT && chars <= THOUGHTS_KEPT_CHARS) break
463    dropped.add(one.i)
464    count--
465    chars -= one.chars
466  }
467  return all.filter(one => !dropped.has(one.i)).map(one => one.t)
468}
469
470// Keeps the thoughts in the store under the session's id (or `sessionId`), for its next
471// run. A session with none keeps nothing. Best effort: a store that cannot be written
472// (it holds 4 MiB at most) keeps what it kept.
473async function keepThoughts($: EngineInterface, sessionId?: string) {
474  keepTimer?.cancel()
475  keepTimer = undefined
476  try {
477    const list = await read($, thoughts)
478    if (list.length === 0) return
479    const kept: KeptThoughts = { at: (await timeNow($)) ?? 0, thoughts: keptList(list) }
480    await $.store.set(thoughtsKey(sessionId ?? (await $.session.id())), kept)
481  } catch {
482    // kept as it was
483  }
484}
485
486// Keeps the thoughts KEEP_MS from now, unless that is pending already: changes in a burst,
487// or one after another without pause, are kept together, at most each KEEP_MS.
488function scheduleKeep($: EngineInterface) {
489  if (keepTimer !== undefined) return
490  keepTimer = $.clock.after(KEEP_MS, () => {
491    keepTimer = undefined
492    void keepThoughts($)
493  })
494}
495
496// Takes up the thoughts the session `from` kept, unless the session has thoughts already
497// (this code was reloaded, which state outlives). A thought that was running when they were
498// kept is running still only if its agent is; else its turn ended with the run that held it,
499// and what waited for that end falls due, as when a turn ends (settleThought), though the
500// voice is not told it ended. The voice has heard of each as it is taken up, so what is new
501// from the thoughts tells it only what changes after.
502async function takeUpThoughts($: EngineInterface, from: string) {
503  if ((await read($, thoughts)).length > 0) return
504  const kept = (await $.store.get(thoughtsKey(from))) as Partial<KeptThoughts> | undefined
505  const list = Array.isArray(kept?.thoughts) ? kept.thoughts : []
506  if (list.length === 0) return
507  const agents = await $.agent.list()
508  const endedAt = kept?.at ?? (await timeNow($))
509  const taken = list.map((t): Thought => {
510    if (t.status !== 'running') return t
511    const status = statusOf(agents.find(a => a.id === t.id)?.status ?? 'completed')
512    if (status === 'running') return t
513    const queued = status === 'stopped' ? [] : withInterruption(t)
514    return { ...t, status, turnId: null, queued, interruptWith: null, isHandOverDue: queued.length > 0, endedAt }
515  })
516  await update($, thoughts, now => (now.length > 0 ? now : taken))
517  await update($, heard, was => ({ ...Object.fromEntries(taken.map(t => [t.id, heardOf(t)])), ...was }))
518  if (await read($, isOn)) for (const t of taken) if (isDue(t)) scheduleDueWake($, t.id, 0)
519  // Under this session's id too, for its next run, should it have come from another.
520  if (from !== (await $.session.id())) await keepThoughts($)
521}
522
523// Drops from the store the thoughts of sessions kept longest ago, past SESSIONS_KEPT of
524// them or SESSIONS_KEPT_CHARS of JSON, this session's own aside. Best effort.
525async function dropOldThoughts($: EngineInterface) {
526  try {
527    const own = thoughtsKey(await $.session.id())
528    const others: { key: string; at: number; chars: number }[] = []
529    for (const key of await $.store.keys()) {
530      if (!key.startsWith('thoughts:') || key === own) continue
531      const kept = await $.store.get(key)
532      const at = (kept as Partial<KeptThoughts> | undefined)?.at
533      others.push({ key, at: typeof at === 'number' ? at : 0, chars: JSON.stringify(kept ?? null).length })
534    }
535    others.sort((a, b) => b.at - a.at)
536    let chars = 0
537    for (const [i, one] of others.entries()) {
538      chars += one.chars
539      if (i >= SESSIONS_KEPT || chars > SESSIONS_KEPT_CHARS) await $.store.delete(one.key)
540    }
541  } catch {
542    // left as they were
543  }
544}
545
546function scheduleFlush($: EngineInterface) {
547  flushTimer?.cancel()
548  flushTimer = $.clock.after(FLUSH_MS, () => void flushInbox($))
549}
550
551// Hands what the thoughts said to the voice, once it is idle: as a row the person never
552// sees, then the wake-up that draws as nothing; failing that, as one plugin message.
553async function flushInbox($: EngineInterface) {
554  if (!(await read($, isOn)) || (await read($, isMainBusy))) return
555  const text = await takeRelay($)
556  if (text === undefined) return
557  if (await appendHidden($, text)) void $.prompt.submit({ text: WAKE, asUser: true })
558  else void $.prompt.submit({ text })
559}
560
561// Puts text in the conversation as a user row the person does not see; whether it went in.
562async function appendHidden($: EngineInterface, text: string): Promise<boolean> {
563  try {
564    const appended = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
565    return appended.deny === undefined
566  } catch {
567    return false
568  }
569}
570
571async function relay($: EngineInterface, fn: (list: Update[]) => Update[]) {
572  if (!(await read($, isOn))) return
573  await update($, inbox, fn)
574  scheduleFlush($)
575}
576
577// What a thought writes as it works is a progress note: kept to follow it by, and
578// relayed to the voice while RELAY_NOTES is on. Its tool calls never are.
579async function noteThoughtSaid($: EngineInterface, id: string, text: string) {
580  const t = (await read($, thoughts)).find(one => one.id === id)
581  // The turn's report, should its row land after the turn ended, has been relayed already.
582  if (t === undefined || text === '' || text === t.lastText) return
583  const now = await timeNow($)
584  // Not through patchThought: a note alone is not kept in the store straight away (KEEP_MS).
585  await update($, thoughts, list =>
586    list.map(one =>
587      one.id !== id
588        ? one
589        : { ...one, notes: [...(one.notes ?? []), text.slice(0, NOTE_MAX)].slice(-NOTES_KEPT), notedSinceReport: true, notedAt: now },
590    ),
591  )
592  if (RELAY_NOTES) await relay($, list => [...list, { thought: t.name, text }])
593}
594
595// The end of a thought's turn reaches the voice once, as a done line. Its report replaces
596// the notes still waiting that it repeats; if it went out already as the latest note,
597// the line is a bare "done". With notes not relayed, nothing went out before it.
598async function relayDone($: EngineInterface, t: Thought, report: string, isFailed: boolean, pending: number) {
599  const notes = t.notes ?? []
600  const wasSent = RELAY_NOTES && report !== '' && notes.at(-1) === report.slice(0, NOTE_MAX)
601  await relay($, list => {
602    const kept = [...list]
603    let isWaiting = false
604    for (let i = kept.length - 1; i >= 0; i--) {
605      const u = kept[i] as Update
606      if (u.thought !== t.name) continue
607      if (u.isDone === true || u.isFromPerson === true || !report.includes(u.text)) break
608      kept.splice(i, 1)
609      isWaiting = true
610    }
611    const said = isWaiting || !wasSent ? report : ''
612    const text = isFailed ? [said, '(ended on an error)'].filter(part => part !== '').join(' ') : said
613    return [...kept, { thought: t.name, text, isDone: true, ...(pending > 0 && { pending }) }]
614  })
615}
616
617// Sends a thought a message: to its agent's id, failing that to its name. The answer says
618// whether it got there and why not; a thought that has it runs again, one that has not
619// keeps the status it had.
620async function deliver($: EngineInterface, t: Thought, text: string): Promise<SessionSendResult> {
621  let was: ThoughtStatus = t.status
622  let wasColor = t.color
623  await patchPicking($, t.id, (one, list, pick) => {
624    was = one.status
625    wasColor = one.color
626    return asRunning(list, one, pick)
627  })
628  const send = async (to: SessionSendAddress): Promise<SessionSendResult> => {
629    try {
630      return await $.session.send({ to, text })
631    } catch (error) {
632      return { isDelivered: false, reason: error instanceof Error ? error.message : String(error) }
633    }
634  }
635  const byId = await send({ agentId: t.id })
636  const sent = byId.isDelivered || !(await send(t.name)).isDelivered ? byId : { isDelivered: true as const }
637  // Not delivered, it goes back to its colour; the turn through the first colours stays
638  // moved on, which does no harm, as a pick passes over the colours held.
639  if (!sent.isDelivered) {
640    await patchThought($, t.id, one => (one.status === 'running' ? { ...one, status: was, color: wasColor } : one))
641  }
642  return sent
643}
644
645// Posts a thought a message, as tell's post does: one running reads it at its next step;
646// one whose turn has ended gets it once its run has ended (it may still be winding down),
647// after the messages still pending for it, and resumes. What the send answered, and how
648// many pending messages went before it.
649async function post(
650  $: EngineInterface,
651  t: Thought,
652  text: string,
653  isRunning: boolean,
654): Promise<{ sent: SessionSendResult; pending: number }> {
655  if (!isRunning) await runEnded($, t.id)
656  const pending = !isRunning && isDue(t) ? t.queued.length : 0
657  const sent = (pending > 0 ? await handOver($, t.id, text) : undefined) ?? (await deliver($, t, text))
658  return { sent, pending }
659}
660
661// The person's own message to a thought, typed in its card in the Thoughts pane: posted as
662// tell posts the voice's, then put before the voice with the thoughts' next updates, so it
663// knows what the thought was asked. Sent from the pane's ui.input hook (below), as auto
664// mode allows only a send made from an event the engine raised to this mod (handOverDue).
665// A message that did not arrive is told in a toast, the field left open.
666async function tellFromPane($: EngineInterface, id: string, text: string) {
667  const message = text.trim()
668  const t = (await read($, thoughts)).find(one => one.id === id)
669  if (message === '' || t === undefined || t.status === 'stopped') return
670  if (!(await read($, isOn))) {
671    $.ui.toast('Multitask is off: turn it on to tell a thought something.')
672    return
673  }
674  const { sent } = await post($, t, fromPerson(message), t.status === 'running')
675  if (!sent.isDelivered) {
676    $.ui.toast(`${t.name} did not get your message: ${sent.reason}`)
677    return
678  }
679  await update($, tellingTo, was => (was === id ? '' : was))
680  await update($, inbox, list => [...list, { thought: t.name, text: message, isFromPerson: true }])
681}
682
683// Queues a message for the end of a thought's turn, if it has a turn running that will
684// end: its place in the queue, or 0 when it has none and nothing would hand it over.
685async function queueFor($: EngineInterface, id: string, text: string): Promise<number> {
686  let at = 0
687  await patchThought($, id, one => {
688    if (one.status !== 'running') return one
689    at = one.queued.length + 1
690    return { ...one, queued: [...one.queued, text] }
691  })
692  return at
693}
694
695// The engine's status of a thought's agent once its run no longer counts as running, or
696// after SETTLE_POLLS looks. A message sent while a finished run is still winding down
697// waits for a tool round that never comes, until something else wakes the session; sent
698// once the run has ended, it resumes the thought straight away.
699async function runEnded($: EngineInterface, id: string): Promise<AgentStatus | undefined> {
700  for (let look = 1; ; look++) {
701    const status = (await $.agent.list()).find(a => a.id === id)?.status
702    if (status !== 'running' || look >= SETTLE_POLLS) return status
703    await new Promise<void>(resolve => $.clock.after(SETTLE_POLL_MS, resolve))
704  }
705}
706
707// Whether a thought has messages due: its queue no longer waits for a turn to end.
708function isDue(t: Thought): boolean {
709  return t.isHandOverDue === true && t.queued.length > 0 && t.status !== 'stopped'
710}
711
712// What a thought is handed for an interruption that waited for its turn to be cut.
713const interruption = (text: string) => `Interruption from the voice: ${text}`
714
715// Its queue, after the interruption waiting for its turn to be cut, if any.
716function withInterruption(t: Thought): string[] {
717  return t.interruptWith === null ? t.queued : [interruption(t.interruptWith), ...t.queued]
718}
719
720// What waits for a thought fell due (isHandOverDue set with it): the next hook of this
721// mod's the engine raises hands it over (handOverDue), and should none have by then, the
722// voice is woken, for its turn to.
723async function fellDue($: EngineInterface, id: string) {
724  dueAt.set(id, await $.clock.now())
725  scheduleDueWake($, id, 0)
726}
727
728// Forgets a thought's due messages: handed over, withdrawn, or the thought stopped.
729function forgetDue(id: string) {
730  dueWakes.get(id)?.cancel()
731  dueWakes.delete(id)
732  dueAt.delete(id)
733  toldRefusal.delete(id)
734}
735
736function scheduleDueWake($: EngineInterface, id: string, step: number) {
737  dueWakes.get(id)?.cancel()
738  dueWakes.delete(id)
739  const ms = DUE_WAKES_MS[step]
740  if (ms !== undefined) dueWakes.set(id, $.clock.after(ms, () => void wakeForDue($, id, step)))
741}
742
743// Messages are still due to a thought, which no hook of this mod's has handed over since
744// they fell due: the voice is told they are still pending, which wakes it, and its turn
745// hands them over. A note already waiting for the voice about the thought wakes it as well.
746async function wakeForDue($: EngineInterface, id: string, step: number) {
747  dueWakes.delete(id)
748  const t = (await read($, thoughts)).find(one => one.id === id)
749  const isInFlight = handingOver.has(id)
750  if (t === undefined || (!isInFlight && !isDue(t))) return
751  scheduleDueWake($, id, step + 1)
752  if (isInFlight) return
753  const note: Update = { thought: t.name, text: `(${pendingNote(t.queued.length)})`, isPending: true }
754  await relay($, list => (list.some(u => u.thought === t.name) ? list : [...list, note]))
755}
756
757// Hands each thought whose turn has ended the messages due to it. Called only from hooks
758// of this mod's that the engine raises (the voice's turns, the engine's notice that a
759// thought finished, tell), as only there does the engine run this mod's allowance of its
760// own SendMessage (tool.check, below). A $ call made after the hook that made it has
761// returned, or from a timer, runs beneath this mod's own frame, where the engine leaves out
762// every hook of this mod, the allowance too: auto mode then refuses the send.
763async function handOverDue($: EngineInterface) {
764  if (!(await read($, isOn))) return
765  const due = (await read($, thoughts)).filter(t => isDue(t) && !handingOver.has(t.id))
766  if (due.length === 0) return
767  const agents = await $.agent.list()
768  const now = await $.clock.now()
769  for (const t of due) {
770    // Still ending the run its turn ended, a message would wait unread (runEnded): it goes
771    // at the next chance. One running a new turn reads it at its next step.
772    const run = agents.find(a => a.id === t.id)?.status
773    const isEnding = run === 'running' && t.turnId === null && now - (dueAt.get(t.id) ?? 0) < SETTLE_MS
774    if (!isEnding) await handOver($, t.id)
775  }
776}
777
778// Hands a thought the messages due to it, and `also` after them (tell's message to an idle
779// one), from a hook of this mod's the engine raised (handOverDue says why). Delivered, they
780// are off its queue and the voice's relay says so; refused, they stay at its front, due
781// still, and the voice is told why, once a reason. Undefined when there was nothing to send.
782async function handOver($: EngineInterface, id: string, also?: string): Promise<SessionSendResult | undefined> {
783  const isFree = !handingOver.has(id)
784  let waited: string[] = []
785  if (isFree) {
786    handingOver.add(id)
787    await patchThought($, id, one => {
788      if (!isDue(one)) return one
789      waited = one.queued
790      return { ...one, queued: [] }
791    })
792  }
793  try {
794    const t = (await read($, thoughts)).find(one => one.id === id)
795    const parts = also === undefined ? waited : [...waited, also]
796    if (t === undefined || parts.length === 0) return undefined
797    const sent = await deliver($, t, parts.join('\n\n'))
798    if (waited.length === 0) return sent
799    if (sent.isDelivered) {
800      await patchThought($, id, one => ({ ...one, isHandOverDue: false }))
801      forgetDue(id)
802      // The voice's relay, if it has not gone out yet, says they reached it.
803      await update($, inbox, list =>
804        list
805          .filter(u => u.thought !== t.name || u.isPending !== true)
806          .map(u => (u.thought === t.name && u.isDone === true && (u.pending ?? 0) > 0 ? { ...u, isHandedOver: true } : u)),
807      )
808      return sent
809    }
810    await patchThought($, id, one =>
811      one.status === 'stopped' ? one : { ...one, queued: [...waited, ...one.queued], isHandOverDue: true },
812    )
813    if (toldRefusal.get(id) !== sent.reason) {
814      toldRefusal.set(id, sent.reason)
815      const text = `(${pendingNote(waited.length)}. The last try failed: ${sent.reason})`
816      await relay($, list => [...list, { thought: t.name, text, isPending: true }])
817    }
818    return sent
819  } finally {
820    if (isFree) handingOver.delete(id)
821  }
822}
823
824// A running thought's turn ended, or it was stopped, at `now`: its dot in the band fades
825// out from then. Ends from FADE_MS ago or more are forgotten.
826function noteEnding(id: string, now: number | undefined) {
827  if (now === undefined) return
828  for (const [one, at] of endings) if (now - at >= FADE_MS) endings.delete(one)
829  endings.set(id, now)
830}
831
832async function stopThought($: EngineInterface, id: string) {
833  const now = await timeNow($)
834  if ((await read($, thoughts)).find(t => t.id === id)?.status === 'running') noteEnding(id, now)
835  await patchThought($, id, t => ({ ...t, status: 'stopped', queued: [], interruptWith: null, isHandOverDue: false, endedAt: now }))
836  forgetDue(id)
837  try {
838    await $.tool.call({ tool: 'TaskStop', task_id: id })
839  } catch {
840    // it had already ended
841  }
842}
843
844async function unqueue($: EngineInterface, id: string, index: number | undefined) {
845  let isEmpty = false
846  await patchThought($, id, t => {
847    const queued = index === undefined ? [] : t.queued.filter((_, i) => i !== index)
848    isEmpty = queued.length === 0
849    return { ...t, queued, isHandOverDue: !isEmpty && t.isHandOverDue === true }
850  })
851  if (isEmpty) forgetDue(id)
852}
853
854// Cuts a thought's running turn so the message goes in at once: the cut turn's end (or
855// the grace timer) makes it due, and it goes at the next chance. With no turn the engine
856// will cut, the message waits in its queue for the run's end, or goes now when the run has
857// ended. The thought is never stopped: a stopped agent takes no more messages. Answers as
858// tell does.
859async function interrupt($: EngineInterface, t: Thought, text: string): Promise<TellAnswer> {
860  let pending = text
861  if (t.turnId !== null) {
862    await patchThought($, t.id, one => ({
863      ...one,
864      interruptWith: one.interruptWith === null ? text : `${one.interruptWith}\n\n${text}`,
865    }))
866    try {
867      await $.turn.abort({ turnId: t.turnId })
868      // Normally its turn.complete makes the message due; this covers a turn that never reports.
869      $.clock.after(INTERRUPT_GRACE_MS, () => void interruptionFallsDue($, t.id))
870      return { result: `Interrupting ${t.name}; it gets the message as soon as its current turn is cut.` }
871    } catch {
872      // That turn had ended. Unless its end made the message due, it goes another way.
873      let taken = null as string | null
874      await patchThought($, t.id, one => {
875        taken = one.interruptWith
876        return { ...one, interruptWith: null }
877      })
878      if (taken === null) {
879        // Due now, it goes from here, this being the voice's own call.
880        await runEnded($, t.id)
881        const sent = await handOver($, t.id)
882        if (sent === undefined || sent.isDelivered) return { result: `${t.name}'s turn had just ended; it has the message now.` }
883        return { result: `${t.name}'s turn had just ended; the message is pending and goes to it at the next chance (${sent.reason}).` }
884      }
885      pending = taken
886    }
887  }
888  if ((await $.agent.list()).find(a => a.id === t.id)?.status === 'running') {
889    const at = await queueFor($, t.id, pending)
890    if (at > 0) return { result: `Could not cut ${t.name}'s turn; the message is queued as #${at}, for when that turn ends.` }
891  }
892  await runEnded($, t.id)
893  const sent = await deliver($, t, pending)
894  if (!sent.isDelivered) return notDelivered(t, sent.reason)
895  return { result: `${t.name} had no turn running; it has the message now and resumes.` }
896}
897
898// Makes the interruption still waiting, if any, due, before the thought's queue: the grace
899// timer's, for a cut turn that never reports. Whichever comes second, the cut turn's end
900// or the grace timer, finds none and leaves the thought be.
901async function interruptionFallsDue($: EngineInterface, id: string) {
902  let isMoved = false
903  await patchThought($, id, t => {
904    if (t.interruptWith === null || t.status === 'stopped') return t
905    isMoved = true
906    return { ...t, queued: withInterruption(t), interruptWith: null, isHandOverDue: true }
907  })
908  if (isMoved) await fellDue($, id)
909}
910
911// The notes a turn's report leaves: its own text arrived as notes too, last.
912function notesBefore(notes: string[], report: string): string[] {
913  const kept = [...notes]
914  while (kept.length > 0 && report.includes(kept.at(-1) as string)) kept.pop()
915  return kept
916}
917
918// A thought's turn ended: it goes idle (or failed), and what waited for that end, an
919// interruption and its queue, falls due; the voice is told, with the text it ended on as
920// its report (a result, question or problem) and how many messages are still pending.
921// Nothing is handed over here: this runs on after the engine's hook has returned (see
922// handOverDue), and the run that turn ended may not have ended yet.
923async function settleThought($: EngineInterface, id: string, reason: TurnCompleteReason, answer: string) {
924  const t = (await read($, thoughts)).find(one => one.id === id)
925  if (t === undefined || t.status === 'stopped') return
926  // A turn that was cut ended on whatever it was doing, not on a report; it was cut by
927  // the voice's interruption or a stop, so the voice is not told it ended.
928  const isCut = reason === 'aborted'
929  const report = isCut ? '' : answer.trim()
930  if (report !== '') {
931    await patchThought($, id, one => ({
932      ...one,
933      lastText: report,
934      notes: notesBefore(one.notes ?? [], report),
935      notedSinceReport: false,
936    }))
937  }
938  // What waits for it is read as it stands now, in the same step that settles it, not as
939  // it stood when the turn ended: the voice may have queued or interrupted since.
940  const isFailed = reason === 'error' || reason === 'refusal'
941  const status: ThoughtStatus = isFailed ? 'failed' : 'idle'
942  let pending = 0
943  const now = await timeNow($)
944  // A cut turn's thought goes on with the message it was cut for: its dot does not fade.
945  if (!isCut && t.status === 'running') noteEnding(id, now)
946  await patchThought($, id, one => {
947    if (one.status === 'stopped') return one
948    const queued = withInterruption(one)
949    pending = queued.length
950    return { ...one, status, turnId: null, queued, interruptWith: null, isHandOverDue: pending > 0, endedAt: now }
951  })
952  if (!isCut) await relayDone($, t, report, isFailed, pending)
953  if (pending > 0) await fellDue($, id)
954}
955
956// What the voice is told beside the person's message: its whole part once Multitask
957// comes on (and again after a compaction), a reminder while it stays on, and once
958// that it has gone off.
959async function voiceNote($: EngineInterface): Promise<string | undefined> {
960  const now = await read($, isOn)
961  const told = await read($, toldVoice)
962  if (now !== told) await update($, toldVoice, () => now)
963  if (now) return told ? VOICE_REMINDER : VOICE
964  return told ? VOICE_OFF : undefined
965}
966
967// What is new from the thoughts the voice has not heard of, but those a relay names (it
968// tells of them itself); none when nothing is. Once told, the voice has heard of them all.
969async function takeNews($: EngineInterface, relayed: ReadonlySet<string> = new Set()): Promise<string | undefined> {
970  const list = await read($, thoughts)
971  const was = await read($, heard)
972  const lines = list.filter(t => !relayed.has(t.name)).flatMap(t => newsOf(t, was[t.id]))
973  await update($, heard, () => Object.fromEntries(list.map(t => [t.id, heardOf(t)])))
974  return lines.length === 0 ? undefined : formatNews(lines)
975}
976
977// The updates waiting for the voice, taken, as one relay with what is new from the other
978// thoughts after them; none when no update waits.
979async function takeRelay($: EngineInterface): Promise<string | undefined> {
980  let taken: Update[] = []
981  await update($, inbox, list => {
982    taken = list
983    return []
984  })
985  if (taken.length === 0) return undefined
986  const news = await takeNews($, new Set(taken.map(u => u.thought)))
987  return news === undefined ? formatUpdates(taken) : `${formatUpdates(taken)}\n\n${news}`
988}
989
990// Waits, a short while at most, for the turn end an engine's notice tells of to be
991// settled (its update waiting for the voice), when the notice names a thought whose turn
992// ended. The notice can come while the settling still runs.
993async function settledFor($: EngineInterface, notice: string) {
994  const found = [...settling].find(([id, s]) => isAbout(notice, id, s.name))
995  if (found === undefined) return
996  await Promise.race([found[1].done, $.clock.sleep(NOTICE_WAIT_MS)])
997  settling.delete(found[0])
998}
999
1000// The voice's reply as it streams, its text held back while it could still be QUIET: then
1001// passed on as it came or, being QUIET, as NO_RESPONSE, which the desktop draws as
1002// nothing. A reply that calls a tool, or thinks after its text, is passed on as it came.
1003async function* unquiet(
1004  stream: HookStream<TurnStepChunk, TurnStepResult>,
1005): AsyncGenerator<TurnStepChunk, TurnStepResult> {
1006  // Every piece since the reply's text began, in order (null once passed on), and that text.
1007  let held: TurnStepChunk[] | null = []
1008  let text = ''
1009  let isRewritten = false
1010  for await (const chunk of stream) {
1011    if (held === null || (held.length === 0 && chunk.kind !== 'text')) {
1012      yield chunk
1013      continue
1014    }
1015    if (chunk.kind === 'stop') {
1016      isRewritten = text.trim() === QUIET
1017      yield* isRewritten ? quieted(held) : held
1018      held = null
1019      yield chunk
1020      continue
1021    }
1022    held.push(chunk)
1023    if (chunk.kind === 'engine') continue
1024    if (chunk.kind === 'text') {
1025      text += chunk.text
1026      if (QUIET.startsWith(text.trim())) continue
1027    }
1028    yield* held
1029    held = null
1030  }
1031  if (held !== null) {
1032    isRewritten = text.trim() === QUIET
1033    yield* isRewritten ? quieted(held) : held
1034  }
1035  const result = await stream.result
1036  return isRewritten ? { ...result, answer: NO_RESPONSE } : result
1037}
1038
1039// The pieces of a quiet reply with its text as NO_RESPONSE, in the first piece of text.
1040function quieted(held: TurnStepChunk[]): TurnStepChunk[] {
1041  const first = held.findIndex(chunk => chunk.kind === 'text')
1042  return held.flatMap((chunk, i): TurnStepChunk[] => {
1043    if (chunk.kind !== 'text') return [chunk]
1044    return i === first ? [{ ...chunk, text: NO_RESPONSE }] : []
1045  })
1046}
1047
1048// The voice's effort: the lighter of the session's and the chosen one, so the voice
1049// that only talks and steers costs less; a budget given as a number stays.
1050function lighterEffort<E>(effort: E, chosen: string): E {
1051  const at = EFFORTS.indexOf(effort as never)
1052  const cap = EFFORTS.indexOf(chosen as never)
1053  return at < 0 || cap < 0 || at <= cap ? effort : (EFFORTS[cap] as E)
1054}
1055
1056export const register: Register = (on, options) => {
1057  const voiceEffort = typeof options.voiceEffort === 'string' ? options.voiceEffort : 'low'
1058
1059  on('session.start', async ($, e, next) => {
1060    await update($, isMainBusy, () => false)
1061    await $.agent.register({
1062      name: 'thought',
1063      description: 'One thought of a multitasking mind; started by the multitask mod only.',
1064      prompt: THOUGHT,
1065      disallowedTools: Object.values(TOOL),
1066    })
1067    // A restarted or resumed session takes up the switch and the thoughts it kept; a
1068    // rewound or forked one, those of the session it came from. Found before the switch
1069    // is kept below, which would make the session its own. One that holds the switch on
1070    // or has thoughts (a reload of this code, which state outlives) keeps them.
1071    const from = await keptSession($).catch(() => undefined)
1072    if (from !== undefined) {
1073      if (await keptSwitch($, from).catch(() => false)) await update($, isOn, () => true)
1074      await takeUpThoughts($, from).catch(() => undefined)
1075    }
1076    await keepSwitch($)
1077    await dropOldThoughts($)
1078    // So the switch and the thoughts outlive a reload of this code (state) and a restart of
1079    // the session (the store): the tools they had are offered again. Never switched on, the
1080    // session gets none.
1081    if ((await read($, isOn)) || (await read($, thoughts)).length > 0) await offerVoiceTools($)
1082    await $.command.register({ name: 'multitask', description: 'Turn Multitask on or off' })
1083    await $.command.register({ name: 'thoughts', description: 'Show the multitasking thoughts in a pane' })
1084    return next(e)
1085  })
1086
1087  // The thoughts are kept in the store as the session ends, with any progress notes since
1088  // they were last kept, under the id of the session ending.
1089  on('session.end', async ($, e, next) => {
1090    await keepThoughts($, e.sessionId)
1091    return next(e)
1092  })
1093
1094  // The engine resumes an ended thought with a message only if its type is offered, so it
1095  // is offered while Multitask is on; the model still cannot start one (tool.check, below).
1096  on('agent.offer', { agent: AGENT }, async ($, e, next) => ((await read($, isOn)) ? next(e) : { isOffered: false }))
1097
1098  on('command.run', { command: 'multitask' }, async $ => {
1099    const now = await toggle($)
1100    await handOverDue($)
1101    return {
1102      text: now
1103        ? 'Multitask is on: this session is now the voice and hands all work to thoughts.'
1104        : 'Multitask is off.',
1105    }
1106  })
1107
1108  on('command.run', { command: 'thoughts' }, async $ => {
1109    await $.ui.open({ id: PANE, title: 'Thoughts' })
1110    return { text: 'Thoughts pane opened.' }
1111  })
1112
1113  // The voice does no work itself: the model's own calls on the main loop are refused.
1114  // And only the voice uses this mod's tools. The engine's hidden forks (a prompt
1115  // suggestion, a memory extraction) are offered the voice's tools, and the engine
1116  // refuses their every call beneath next(e); the tools' own hooks below answer
1117  // without calling it, so a call from any loop but the main one is refused here,
1118  // this hook being registered first and so running outside them. A fork's or a
1119  // subagent's call carries its loop's agentId; the voice's carries none.
1120  on('tool.call', async ($, e, next) => {
1121    if (e.agentId !== undefined && OWN_TOOLS.has(String(e.tool))) return NOT_VOICE
1122    const isVoiceCall = e.agentId === undefined && next.origin.plugin === 'engine'
1123    if (!isVoiceCall || VOICE_TOOLS.test(String(e.tool)) || !(await read($, isOn))) return next(e)
1124    return {
1125      deny: 'Multitask is on: you are the voice and do no work yourself. Hand this to a thought (think, or tell an existing one).',
1126    }
1127  })
1128
1129  on('tool.call', { tool: 'mcp__multitask__think' }, async ($, e) => {
1130    if (!(await read($, isOn))) return OFF
1131    const task = str(e, 'task')
1132    if (task === '') return { deny: 'Give the thought a task.' }
1133    const list = await read($, thoughts)
1134    // The voice names it by its task; failing that, the task's first words do.
1135    const name = uniqueName(list, slug(str(e, 'name')) || slug(task) || 'thought')
1136
1137    const spawned = await $.agent.spawn({ prompt: task, description: name, name, subagentType: AGENT })
1138    if (spawned.deny !== undefined) return { deny: `The thought did not start: ${spawned.deny}` }
1139    // The id normally comes back with the spawn; failing that, find it among the agents.
1140    const id =
1141      spawned.agentId ??
1142      (await $.agent.list()).filter(a => a.description === name && a.type === AGENT).at(-1)?.id
1143    if (id === undefined) return { deny: 'The thought did not start: no agent id came back.' }
1144
1145    const thought: Omit<Thought, 'color'> = {
1146      id,
1147      name,
1148      task,
1149      status: 'running',
1150      queued: [],
1151      lastText: '',
1152      notes: [],
1153      turnId: null,
1154      interruptWith: null,
1155      startedAt: await timeNow($),
1156    }
1157    // Its colour is picked as it joins the list, so thoughts started together differ.
1158    await updateThoughts($, (all, pick) => [...all, { ...thought, color: pick(all) }])
1159    return { result: `Started thought "${name}". Its updates reach you as they come.` }
1160  })
1161
1162  on('tool.call', { tool: 'mcp__multitask__tell' }, async ($, e) => {
1163    if (!(await read($, isOn))) return OFF
1164    const list = await read($, thoughts)
1165    const t = findThought(list, refOf(e))
1166    if (t === undefined) return unknownThought(list, refOf(e))
1167    const message = str(e, 'message')
1168    if (message === '') return { deny: 'Give a message.' }
1169    const how = (str(e, 'how') || str(e, 'mode') || 'post').toLowerCase()
1170    if (!TELL_HOWS.includes(how)) {
1171      return { deny: `Unknown how "${how}": give \`how\` as post, queue or interrupt. Nothing was sent.` }
1172    }
1173    let isRunning = t.status === 'running'
1174
1175    if (how === 'queue' && isRunning) {
1176      const at = await queueFor($, t.id, message)
1177      if (at > 0) return { result: `Queued for ${t.name} as #${at}; it reads it when its turn ends.` }
1178      isRunning = false // its turn ended meanwhile
1179    }
1180    if (how === 'interrupt' && isRunning) return interrupt($, t, message)
1181    // With no turn running, its last run may still be winding down: the message goes once it
1182    // has ended, after what is still pending for it.
1183    const { sent, pending } = await post($, t, message, isRunning)
1184    if (!sent.isDelivered) return notDelivered(t, sent.reason)
1185    const after = pending === 0 ? '' : `, after the ${pending === 1 ? 'message' : `${pending} messages`} still pending for it,`
1186    return {
1187      result: isRunning
1188        ? `Posted to ${t.name}; it reads it at its next step.`
1189        : `${t.name} was ${t.status === 'running' ? 'idle' : t.status}; it has the message now${after} and resumes.`,
1190    }
1191  })
1192
1193  on('tool.call', { tool: 'mcp__multitask__unqueue' }, async ($, e) => {
1194    const list = await read($, thoughts)
1195    const t = findThought(list, refOf(e))
1196    if (t === undefined) return unknownThought(list, refOf(e))
1197    const raw = (e as Record<string, unknown>).index
1198    const index = typeof raw === 'number' ? raw - 1 : undefined
1199    if (index !== undefined && (index < 0 || index >= t.queued.length)) {
1200      return { deny: `${t.name} has ${t.queued.length} queued message(s).` }
hooks/prompts.ts 192 lines
1import type { Heard, Thought, Update } from '../types'
2
3// What the voice replies when it has nothing to say.
4export const QUIET = '(quiet)'
5
6// What a quiet reply goes on as, and is kept as: a reply the desktop draws as nothing.
7export const NO_RESPONSE = 'No response requested.'
8
9export const isQuiet = (text: string) => text.trim() === QUIET || text.trim() === NO_RESPONSE
10
11// How every relay of the thoughts' updates to the voice begins, on a line of its own.
12export const RELAY_HEAD = 'Thought updates:'
13
14// How the note of what is new from the thoughts begins, beside the person's messages and
15// after a relay's updates.
16export const NEWS_HEAD = "What's new from your thoughts:"
17
18// The prompt that wakes the voice to read the updates just handed to it as a hidden row.
19// It is shaped as a local command's output (a /model echo), which Claude Code does not
20// replay to the desktop and the desktop draws as nothing; it is submitted as the person's
21// own words, so it is read bare. The voice's brief says what it is.
22export const WAKE =
23  '<local-command-stdout>Set model to unchanged (multitask wake-up: read the thought updates just above)</local-command-stdout>'
24
25export const isWakeText = (text: string) => text.includes('(multitask wake-up: read the thought updates just above)')
26
27// TEMPORARY: whether a thought's mid-turn progress notes are relayed to the voice. Off
28// for now: each relay adds transcript rows, which leave gaps in the desktop chat, until
29// a hidden delivery route is found. Notes are still kept (Thoughts pane, thoughts tool),
30// and the end of each turn is still relayed. Set true to switch back; the prompts follow.
31export const RELAY_NOTES: boolean = false
32
33export const VOICE = `# Multitasking mind
34
35Multitasking is on. You are the voice of a multitasking mind: you talk with the user, and your thoughts (background agents you start and steer) do all of the actual thinking and work. You do none of it yourself: reading, searching, running and editing are refused to you.
36
37Your tools:
38- think: start a thought on a task. Name each thought briefly by its task, in a few words of kebab-case (fix-login-expiry); you address it by that name.
39- tell: give a thought a message. Its parameters are \`name\` (the thought's name), \`message\` (the text) and \`how\`: post (the default; it reads it at its next step), queue (it reads it once it finishes its current turn; you can still unqueue it) or interrupt (cut what it is doing and give it this now).
40- unqueue: withdraw messages you queued for a thought.
41- stop: end a thought.
42- thoughts: every thought's status, queue, latest progress notes and last report, or one thought's recent transcript.
43
44Updates from your thoughts reach you as rows the user never sees (failing that, as messages from the multitask plugin). When they arrive while you are idle, a wake-up follows them: a line "Set model to unchanged (multitask wake-up: read the thought updates just above)". It is not from the user and changes no model; it only means read the updates just above it. Neither updates nor wake-ups need a reply. Each update starts "${RELAY_HEAD}", then one line per update: ${RELAY_NOTES ? '"name: text" is a progress note or report, ' : ''}"name (done): text" the text a turn ended with, "name: done" a turn that ended with nothing more, "name (from the user): text" a message the user sent that thought themselves from the Thoughts pane, which it acts on as it would on yours, "name: (...)" messages of yours still pending for it, which reach it at the next chance and you hear back after (with why the last try failed, if one did)${RELAY_NOTES ? '' : ' (for now their progress notes are not sent to you; they are shown in the Thoughts pane)'}; your default reply is ${QUIET}. For each, choose:
45- Say nothing. Reply with exactly ${QUIET} and nothing else; the user never sees it. This is the default.
46- Steer: post, queue or interrupt a thought, unqueue what is no longer wanted, stop a thought, or start another.
47- Speak to the user, only when something deserves their attention: a result they asked for, a decision only they can make, a problem. Use your own words, briefly.
48
49When a thought's turn ends, its update may come in place of the engine's own notice that the thought finished. After the updates, and beside the user's messages and the engine's notices, there may be a short note starting "${NEWS_HEAD}": the thoughts that finished, failed or were stopped since you were last told, and the latest progress notes of those still running. It needs no reply either, and an update may repeat what it said. Your history keeps each ${QUIET} reply as "${NO_RESPONSE}"; both mean you said nothing.
50
51Never restate or summarise an update just because it arrived, and never narrate what the thoughts are doing unless the user asks. When the user asks for something, start or steer thoughts and keep track of what they asked for until the thoughts have delivered it.
52
53Thoughts work in the user's own checkout; the only way to set one apart is to ask in its brief. Before starting a thought that will edit files, check what the running thoughts are changing (thoughts), and give each editing thought its own files or have its brief ask for a git worktree on a branch of its own. A worktree is worth it when edits could collide with another thought's, or with files the user or live tooling (a dev server, a watcher, anything that reloads on save) reads while the work runs, or for long or risky changes across many files. Leave it out for reading and research, small edits to files nothing else touches, and work the user must see live. In auto mode the files of a plugin loaded live in this session can't be edited in place, so a thought changing them needs a git worktree and a merge back.
54
55When two tasks need the same files, queue the second on the thought doing the first, or wait for it, or split the work by file. A thought in a worktree hands back a branch: tell the user, and have a thought merge it into their checkout only when they ask. Once a thought's branch is merged, have its worktree and branch removed; raise unmerged ones with the user rather than leaving them behind.
56
57To the user you are one mind. Your thoughts are your own thinking, not workers you manage in front of them, so never speak of thoughts as items or workers ("I've started a thought to dig into this", "the thought will report back"). Speak naturally, as a person does about their own work: "I'm looking into which rows we can hide", "I'll let you know what I find". When you start or steer thoughts for a request, keep your message very brief, often a few words; if an acknowledgement adds nothing, reply exactly ${QUIET}.`
58
59// Beside each of the person's messages while Multitask stays on, once the voice has its part.
60export const VOICE_REMINDER = `Multitask is on: you are the voice. Hand work to thoughts (think, tell) and do none yourself. To the user, speak as one mind about your own work ("I'm looking into it"), never of thoughts as workers; keep acknowledgements to a few words, or reply ${QUIET} when nothing needs saying.`
61
62// Beside the person's next message once Multitask is turned off.
63export const VOICE_OFF = `Multitask is now off: you are no longer the voice. Work directly with your own tools again. Thoughts you started may still be running; their updates no longer reach you, only the engine's own notice when one finishes.`
64
65export const THOUGHT =`You are one thought in a multitasking mind. Several thoughts run at once; one voice talks with the user and steers you. You do the actual thinking and work for your task: read, search, run, edit, test, whatever it needs.
66
67Before each meaningful step, write a one-line progress note in plain words, such as "Checking how the engine stamps plugin prompts". ${RELAY_NOTES ? 'Your notes reach the voice as brief updates so it can follow your work; your tool calls do not.' : 'For now your notes are shown in the Thoughts pane, and the voice sees only your latest ones, now and then; your tool calls are not shown there.'} No pleasantries, and no note for every small tool call.
68
69You share the user's checkout with them and other thoughts: keep to the files your task needs. If your brief asks for a worktree, or you will edit files something reads live (a dev server, a watcher, anything that reloads on save), work in a git worktree instead.
70
71Make it with git worktree add on a new branch, in a folder outside the project so its watchers and builds leave it alone; not with EnterWorktree, which would move the whole session. Use absolute paths into it, as your shell's directory resets between calls. A new worktree lacks ignored files (installed packages, generated files): set up or copy in what your build and tests need. Finish by committing on the branch and reporting the branch and folder; leave the worktree for whoever merges it. Never merge into the user's checkout or commit to their main branch unless asked.
72
73The text you end your turn with reaches the voice as your report. So when you finish, end your turn with a short, plain report the voice can act on: what you found or changed, where, and anything still open. If you need an answer, or a problem blocks you, do not bury it in a note: end your turn with the question or the problem, plainly, and wait.
74
75Messages from the voice, or now and then from the user directly, may arrive while you work. They steer you: take them into account straight away, and if one changes your task, follow it.`
76
77// A text on one line.
78const flat = (text: string) => text.replace(/\s+/g, ' ').trim()
79
80// The person's own message to a thought, sent from the Thoughts pane, as the thought reads it.
81export const fromPerson = (text: string) => `The user, from the Thoughts pane: ${text}`
82
83// A text on one line, cut to at most `max` characters.
84export function oneLine(text: string, max: number): string {
85  const line = flat(text)
86  return line.length > max ? `${line.slice(0, max - 1)}…` : line
87}
88
89// What a relay says of messages of the voice's still pending for a thought: they will
90// reach it, and the voice hears from it after.
91export function pendingNote(count: number): string {
92  const what = count === 1 ? '1 message' : `${count} messages`
93  return `${what} still pending; ${count === 1 ? 'it' : 'they'} will be handed over and you'll hear back`
94}
95
96// What a relay says once the messages that waited for a thought's turn to end reached it.
97function handedOverNote(count: number): string {
98  return `it has your ${count === 1 ? 'queued message' : `${count} queued messages`} now; you'll hear back`
99}
100
101// One line an update: "name: text" a note (or a report already sent as one),
102// "name (done): text" the text a turn ended with, "name: done" a turn ended bare; a
103// turn's end then says what became of the messages that waited for it. "name (from the
104// user): text" is the person's own message to the thought, from the Thoughts pane.
105export function formatUpdates(updates: Update[]): string {
106  const lines = updates.map(u => {
107    if (u.isFromPerson === true) return `${u.thought} (from the user): ${flat(u.text)}`
108    if (u.isDone !== true) return `${u.thought}: ${flat(u.text)}`
109    const pending = u.pending ?? 0
110    const queue = pending === 0 ? '' : ` — ${u.isHandedOver === true ? handedOverNote(pending) : pendingNote(pending)}`
111    return u.text === '' ? `${u.thought}: done${queue}` : `${u.thought} (done): ${flat(u.text)}${queue}`
112  })
113  return `${RELAY_HEAD}\n${lines.join('\n')}`
114}
115
116// How many of a running thought's notes a what's-new note carries, at most, and how long each.
117const NEWS_NOTES = 2
118const NEWS_MAX = 160
119
120// One line a thought whose turn ended, failed or was stopped, with its report's start.
121export function newsLine(t: Thought): string {
122  const report = t.lastText === '' ? '' : `: ${oneLine(t.lastText, NEWS_MAX)}`
123  if (t.status === 'stopped') return `${t.name} was stopped`
124  if (t.status === 'failed') return `${t.name} ended on an error${report}`
125  return `${t.name} finished${report}`
126}
127
128// What the voice has not heard of a thought, one line each: that it finished, failed or
129// was stopped; while it runs, the latest notes it wrote since the voice last heard of it.
130export function newsOf(t: Thought, heard: Heard | undefined): string[] {
131  if (t.status === 'running') {
132    const notes = t.notes ?? []
133    const since = heard === undefined ? 0 : notes.lastIndexOf(heard.note) + 1
134    return notes.slice(since).slice(-NEWS_NOTES).map(n => `${t.name}: ${oneLine(n, NEWS_MAX)}`)
135  }
136  if (heard?.status === t.status && heard.report === t.lastText) return []
137  return [newsLine(t)]
138}
139
140// What the voice has heard of a thought once it is told of it as it is now.
141export const heardOf = (t: Thought): Heard => ({ status: t.status, report: t.lastText, note: t.notes?.at(-1) ?? '' })
142
143export const formatNews = (lines: string[]) => `${NEWS_HEAD}\n${lines.join('\n')}`
144
145// A relay row as a surface holds it: bare, or still in the engine's frame for a
146// plugin's prompt ("The multitask plugin sent a message:"), as the desktop shows it.
147// The header ends its line, so a person's own words starting the same way stay theirs.
148const RELAY_ROW = new RegExp(
149  `^\\s*(?:The multitask plugin sent a message[^:\\n]*:\\s*)?${RELAY_HEAD.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[ \\t]*\\r?\\n`,
150)
151
152export const isRelayText = (text: string) => RELAY_ROW.test(text)
153
154// The thoughts a relay's update lines name, each once, in order.
155export function relayNames(text: string): string[] {
156  const at = text.search(RELAY_ROW)
157  if (at < 0) return []
158  const names = new Set<string>()
159  for (const line of text.slice(text.indexOf(RELAY_HEAD, at) + RELAY_HEAD.length).trimStart().split(/\r?\n/)) {
160    const name = /^([^\s:]+)(?: \((?:done|from the user)\))?: /.exec(line)?.[1]
161    if (name === undefined) break
162    names.add(name)
163  }
164  return [...names]
165}
166
167export function describeThoughts(thoughts: Thought[]): string {
168  if (thoughts.length === 0) return 'No thoughts yet.'
169  return thoughts
170    .map(t => {
171      const queue = t.queued.length === 0 ? '' : `\n  queued: ${t.queued.map((q, i) => `${i + 1}. ${q}`).join(' | ')}`
172      const notes = (t.notes ?? []).slice(-3).map(n => `\n  · ${oneLine(n, 160)}`).join('')
173      const last = t.lastText === '' ? '' : `\n  report: ${t.lastText.slice(0, 300)}`
174      return `- ${t.name} [${t.status}]: ${t.task.slice(0, 200)}${queue}${notes}${last}`
175    })
176    .join('\n')
177}
178
179// Each message's start with its spacing dropped, hashed: how a session rewound or forked
180// from another, which starts under a new id with copies of the messages it kept, is
181// matched to it. A start too short to tell sessions apart ("ok", "(quiet)") is left out.
182export function marksOf(messages: readonly { text: string }[]): string[] {
183  return messages
184    .map(m => m.text.replace(/\s+/g, '').slice(0, 80))
185    .filter(start => start.length >= 12)
186    .map(start => {
187      let hash = 0x811c9dc5
188      for (let i = 0; i < start.length; i++) hash = Math.imul(hash ^ start.charCodeAt(i), 0x01000193)
189      return (hash >>> 0).toString(16).padStart(8, '0')
190    })
191}
192
hooks/pane.tsx 420 lines
1import type { Color, ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import type { Thought, ThoughtStatus } from '../types'
4import { oneLine } from './prompts'
5
6// The Thoughts pane: running thoughts first, each a card in its colour's border; the
7// rest one line each, the one most recently ended first, until opened, the
8// ENDED_SHOWN most recently ended alone until `N more` shows them all. A card sets
9// each kind of line apart by a label at its left: the task, the progress notes (the
10// latest at full strength with its age, in the warning colour once the thought has
11// been quiet a while), the report, and the messages queued for its turn's end. A card's
12// `tell` opens a field at its foot to send the thought a message of the person's own.
13//
14// It is drawn as the thread view is (thread-chat's pane.tsx): plain functions here,
15// every hook and every call on `$` in register.tsx, which hands them what they draw
16// with. A plugin hooks an event once without a matcher, and the engine follows `$`
17// only into functions of the file that uses it.
18
19const GLYPH: Record<ThoughtStatus, string> = { running: '◐', idle: '●', stopped: '×', failed: '!' }
20
21// What each status reads as: a turn that ended is done, though a message resumes it.
22const STATUS_WORD: Record<ThoughtStatus, string> = { running: 'running', idle: 'done', stopped: 'stopped', failed: 'failed' }
23
24// The order thoughts are listed in: running ones, then those that failed, then done,
25// then stopped.
26const RANK: Record<ThoughtStatus, number> = { running: 0, failed: 1, idle: 2, stopped: 3 }
27
28// How long a running thought may go without a note, or since its turn started, before
29// its age is drawn in the warning colour.
30export const QUIET_MS = 5 * 60_000
31
32// How many thoughts that are not running the pane lists, the most recently ended, until
33// `N more` shows them all.
34export const ENDED_SHOWN = 16
35
36// Notes a card shows, at most: folded, and opened.
37const NOTES_FOLDED = 3
38const NOTES_OPENED = 5
39
40// The most of a report drawn when opened: what one Markdown element takes.
41const REPORT_MAX = 10_000
42
43// Cells the labels at a card's left take, the gap after them included.
44const LABEL_COLUMNS = 7
45
46// How a thought's colour is drawn: as it is, or dim (see drawThoughts).
47type Tint = { color: Color; dimColor?: true }
48
49// The key of a card's field to tell its thought something: this, then the thought's id.
50export const TELL_FIELD = 'tell-field:'
51
52// A card's padding inside its border, as the thread view pads a reply's thread part: on
53// the desktop on every side (a bordered Box there is padded 8px above and below unless
54// told otherwise); on the terminal, where a row is a whole line, at the sides alone.
55const DESKTOP_CARD_PADDING = { padding: 1 } as const
56const CARD_PADDING = { paddingX: 1 } as const
57
58// A span of time, short: under a minute, minutes, hours and minutes, or days.
59export function spanOf(ms: number): string {
60  const minutes = Math.floor(Math.max(0, ms) / 60_000)
61  if (minutes < 1) return '<1m'
62  if (minutes < 60) return `${minutes}m`
63  const hours = Math.floor(minutes / 60)
64  if (hours < 10) return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
65  if (hours < 48) return `${hours}h`
66  return `${Math.floor(hours / 24)}d`
67}
68
69// How long ago a time was, as the pane says it.
70export const agoOf = (ms: number) => (ms < 60_000 ? 'just now' : `${spanOf(ms)} ago`)
71
72// A task's first line, which a running card shows until it is opened.
73export function firstLine(task: string): string {
74  return task.trimStart().split('\n')[0]?.trimEnd() ?? ''
75}
76
77// The thoughts in the order the pane lists them: running ones as they were started,
78// the rest the one most recently ended first.
79export function orderOf(list: readonly Thought[]): Thought[] {
80  return list
81    .map((t, i) => ({ t, i }))
82    .sort((a, b) => {
83      const rank = RANK[a.t.status] - RANK[b.t.status]
84      if (rank !== 0) return rank
85      if (a.t.status === 'running') return a.i - b.i
86      return (b.t.endedAt ?? 0) - (a.t.endedAt ?? 0) || b.i - a.i
87    })
88    .map(({ t }) => t)
89}
90
91// The thoughts not running beyond the ENDED_SHOWN most recently ended, by id, but those
92// opened: what the pane lists only once `N more` is pressed.
93export function endedBeyond(list: readonly Thought[], opened: readonly string[]): Set<string> {
94  const ended = list
95    .map((t, i) => ({ t, i }))
96    .filter(({ t }) => t.status !== 'running')
97    .sort((a, b) => (b.t.endedAt ?? 0) - (a.t.endedAt ?? 0) || b.i - a.i)
98  return new Set(ended.slice(ENDED_SHOWN).map(({ t }) => t.id).filter(id => !opened.includes(id)))
99}
100
101// Whether a thought's report is shown: once it has one and has stopped working, and
102// unless it wrote notes since (an old report would read as the result of new work).
103export const showsReport = (t: Thought) => t.lastText !== '' && t.status !== 'running' && t.notedSinceReport !== true
104
105// How long a running thought has gone without a note, or since its turn started if
106// that came later; none where neither time is known.
107export function quietFor(t: Thought, now: number | undefined): number | undefined {
108  const times = [t.notedAt, t.startedAt].filter((at): at is number => at !== undefined)
109  return times.length === 0 || now === undefined ? undefined : now - Math.max(...times)
110}
111
112// How long ago `at` was, as agoOf says it; none where either time is not known.
113const agoSince = (at: number | undefined, now: number | undefined) =>
114  at === undefined || now === undefined ? undefined : agoOf(now - at)
115
116// What the pane is drawn with, as register.tsx reads it while drawing: the surface and
117// its elements, the time (none where it cannot be read: no ages are drawn), the pane's
118// width, which thoughts and reports are opened, and what each control does.
119export type ThoughtsDrawing = {
120  surface: RenderSurface
121  ui: ElementTable
122  now: number | undefined
123  isOn: boolean
124  bodyColumns: number
125  opened: readonly string[]
126  shownReports: readonly string[]
127  toggleExpanded: (id: string) => void
128  toggleReport: (id: string) => void
129  stop: (id: string) => void
130  unqueue: (id: string, index: number) => void
131  // the thought whose card has its field open ('' none), what opens or closes it, and
132  // what Enter there sends, should the pane's ui.input hook not take it first
133  tellingTo: string
134  toggleTell: (id: string) => void
135  tell: (id: string, text: string) => void
136  // whether every thought not running is listed (`N more` pressed), and what toggles it
137  showsAllEnded: boolean
138  toggleShowsAllEnded: () => void
139}
140
141// The field a card's thought is told something in, where the surface draws one (the
142// mobile app draws none yet, though its table may hand one over) while Multitask is on:
143// off, a thought whose turn ended cannot be resumed.
144function tellFieldOf(draw: ThoughtsDrawing) {
145  if (!draw.isOn || draw.surface === 'mobile' || !('Input' in draw.ui)) return undefined
146  return draw.ui.Input
147}
148
149// The pane's body: a line of how many thoughts there are, by status, then each one, and,
150// with more ended than ENDED_SHOWN, `N more` (or `less`) at the end.
151export function drawThoughts(draw: ThoughtsDrawing, list: readonly Thought[]): RenderElement {
152  const { Box, Text, Button } = draw.ui
153  const counts = (['running', 'failed', 'idle', 'stopped'] as const)
154    .map(status => ({ status, count: list.filter(t => t.status === status).length }))
155    .filter(c => c.count > 0)
156    .map(c => `${c.count} ${STATUS_WORD[c.status]}`)
157  const summary = list.length === 0 ? 'No thoughts yet.' : counts.join(' · ')
158  // Only running thoughts hold their colours: one that ended keeps its own, dimmed while a
159  // running thought has taken it, so the two are not mistaken for each other.
160  const held = new Set(list.filter(t => t.status === 'running').map(t => t.color))
161  const tintOf = (t: Thought): Tint => ({ color: t.color, ...(t.status !== 'running' && held.has(t.color) && { dimColor: true }) })
162  const beyond = endedBeyond(list, draw.opened)
163  const shown = draw.showsAllEnded ? orderOf(list) : orderOf(list).filter(t => !beyond.has(t.id))
164  return (
165    <Box flexDirection="column">
166      <Box marginBottom={1}>
167        <Text dimColor>
168          Multitask is {draw.isOn ? 'on' : 'off'} · {summary}
169        </Text>
170      </Box>
171      {shown.map(t =>
172        t.status !== 'running' && !draw.opened.includes(t.id) ? drawLine(draw, t, tintOf(t)) : drawCard(draw, t, tintOf(t)),
173      )}
174      {beyond.size > 0 && (
175        <Box key="ended-more" flexDirection="row">
176          <Button
177            key="more-ended"
178            plain
179            dimColor
180            label={draw.showsAllEnded ? 'less' : `${beyond.size} more`}
181            onPress={() => draw.toggleShowsAllEnded()}
182          />
183        </Box>
184      )}
185    </Box>
186  )
187}
188
189// What a thought's status says, in its colour, with how long it has been so: running
190// for how long its turn has run, else how long ago it ended.
191function drawStatus(draw: ThoughtsDrawing, t: Thought): RenderElement {
192  const { Text } = draw.ui
193  const at = t.status === 'running' ? t.startedAt : t.endedAt
194  const { now } = draw
195  const age = at === undefined || now === undefined ? '' : t.status === 'running' ? ` for ${spanOf(now - at)}` : ` ${agoOf(now - at)}`
196  const look: { color?: Color; dimColor?: boolean } =
197    t.status === 'running' ? { color: t.color } : t.status === 'failed' ? { color: 'error' } : { dimColor: true }
198  return (
199    <Text {...look} wrap="truncate-end">
200      {STATUS_WORD[t.status]}
201      {age}
202    </Text>
203  )
204}
205
206// A thought that is not running, folded to one line: its name, status, its report's
207// first line, and stop.
208function drawLine(draw: ThoughtsDrawing, t: Thought, tint: Tint): RenderElement {
209  const { Box, Text, Button } = draw.ui
210  return (
211    <Box key={`line:${t.id}`} flexDirection="row" columnGap={1} alignItems="center">
212      <Box flexShrink={0}>
213        <Text {...tint}>{GLYPH[t.status]}</Text>
214      </Box>
215      <Box flexShrink={0}>
216        <Button key={`expand:${t.id}`} plain label={t.name} hover={tint} onPress={() => draw.toggleExpanded(t.id)} />
217      </Box>
218      <Box flexShrink={0}>{drawStatus(draw, t)}</Box>
219      <Box flexGrow={1} flexShrink={1} minWidth={0}>
220        {showsReport(t) && (
221          <Text dimColor wrap="truncate-end">
222            {oneLine(t.lastText, 200)}
223          </Text>
224        )}
225      </Box>
226      {t.status !== 'stopped' && (
227        <Box flexShrink={0}>
228          <Button key={`stop:${t.id}`} plain dimColor hover={tint} label="stop" onPress={() => draw.stop(t.id)} />
229        </Box>
230      )}
231    </Box>
232  )
233}
234
235// One kind of a card's lines under its label: the label on the first line alone, dim
236// or in `tint`, the lines beside it, `gap` rows below what is above.
237function drawSection(draw: ThoughtsDrawing, label: string, lines: RenderElement[], gap: number, tint?: Tint): RenderElement {
238  const { Box, Text } = draw.ui
239  return (
240    <Box flexDirection="row" marginTop={gap}>
241      <Box width={LABEL_COLUMNS} flexShrink={0}>
242        <Text {...(tint ?? { dimColor: true })}>{label}</Text>
243      </Box>
244      <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
245        {lines}
246      </Box>
247    </Box>
248  )
249}
250
251// A thought's card: its name, status and controls; then its task, its notes, its
252// report and its queue, each kind under its label.
253function drawCard(draw: ThoughtsDrawing, t: Thought, tint: Tint): RenderElement {
254  const { Box, Text, Button, Markdown } = draw.ui
255  const Input = t.status === 'stopped' ? undefined : tellFieldOf(draw)
256  const isTelling = Input !== undefined && draw.tellingTo === t.id
257  const isPage = draw.surface !== 'terminal'
258  // half a line between kinds on a page; none on the terminal, where the labels part them
259  const gap = isPage ? 1 : 0
260  const isOpened = draw.opened.includes(t.id)
261  const isRunning = t.status === 'running'
262  // Cells a card's task line has: the pane's body less the card's border and padding and the labels.
263  const taskColumns = draw.bodyColumns - 4 - LABEL_COLUMNS
264  const task = firstLine(t.task)
265  const hasMore = isRunning && (t.task.trim() !== task || task.length > taskColumns)
266  const control = { plain: true, dimColor: true, hover: tint } as const
267
268  const notes = (t.notes ?? []).slice(isOpened ? -NOTES_OPENED : -NOTES_FOLDED)
269  const quiet = isRunning ? quietFor(t, draw.now) : undefined
270  const isQuiet = quiet !== undefined && quiet >= QUIET_MS
271  const noteAge = agoSince(t.notedAt, draw.now)
272  const noteLines = notes.map((note, i) => {
273    const isLatest = i === notes.length - 1
274    const text = isOpened ? note : oneLine(note, 200)
275    const wrap = isOpened ? 'wrap' : 'truncate-end'
276    if (!isLatest) {
277      return (
278        <Text dimColor wrap={wrap}>
279          {text}
280        </Text>
281      )
282    }
283    return (
284      <Box flexDirection="row" columnGap={2}>
285        <Box flexGrow={1} flexShrink={1} minWidth={0}>
286          <Text wrap={wrap}>{text}</Text>
287        </Box>
288        {noteAge !== undefined && (
289          <Box flexShrink={0}>
290            <Text {...(isQuiet ? { color: 'warning' } : { dimColor: true })}>{noteAge}</Text>
291          </Box>
292        )}
293      </Box>
294    )
295  })
296  // A running thought with no notes yet says how long it has been at it.
297  const started = agoSince(t.startedAt, draw.now)
298  if (noteLines.length === 0 && isRunning && started !== undefined) {
299    noteLines.push(
300      <Text {...(isQuiet ? { color: 'warning' } : { dimColor: true })} wrap="truncate-end">
301        none yet, started {started}
302      </Text>,
303    )
304  }
305
306  const isReportShown = draw.shownReports.includes(t.id)
307  const report = showsReport(t) && (
308    <Box key={`reportbox:${t.id}`} flexDirection="column">
309      {isReportShown && <Markdown text={t.lastText.length > REPORT_MAX ? `${t.lastText.slice(0, REPORT_MAX - 1)}…` : t.lastText} />}
310      <Box flexDirection="row" columnGap={1}>
311        {!isReportShown && (
312          <Box flexGrow={1} flexShrink={1} minWidth={0}>
313            <Text wrap="truncate-end">{oneLine(t.lastText, 200)}</Text>
314          </Box>
315        )}
316        {/* Lit in the thought's colour with the pointer anywhere on the report; the line gives way, not it. */}
317        <Box flexShrink={0}>
318          <Button
319            key={`report:${t.id}`}
320            plain
321            dimColor
322            hover={tint}
323            label={isReportShown ? 'less' : 'more'}
324            onPress={() => draw.toggleReport(t.id)}
325          />
326        </Box>
327      </Box>
328    </Box>
329  )
330
331  const queued = t.queued.map((q, i) => (
332    <Box flexDirection="row" columnGap={1}>
333      <Box flexShrink={0}>
334        <Text dimColor>{i + 1}.</Text>
335      </Box>
336      <Box flexGrow={1} flexShrink={1} minWidth={0}>
337        <Text wrap={isOpened ? 'wrap' : 'truncate-end'}>{isOpened ? q : oneLine(q, 200)}</Text>
338      </Box>
339      <Box flexShrink={0}>
340        <Button key={`unqueue:${t.id}:${i}`} {...control} label="unqueue" onPress={() => draw.unqueue(t.id, i)} />
341      </Box>
342    </Box>
343  ))
344
345  return (
346    <Box
347      key={`card:${t.id}`}
348      flexDirection="column"
349      borderStyle="round"
350      borderColor={tint.color}
351      {...(tint.dimColor && { borderDimColor: true })}
352      {...(draw.surface === 'desktop' ? DESKTOP_CARD_PADDING : CARD_PADDING)}
353      marginBottom={1}
354    >
355      <Box flexDirection="row" columnGap={1} alignItems="center">
356        <Box flexShrink={0}>
357          <Text {...tint} bold>
358            {GLYPH[t.status]}
359          </Text>
360        </Box>
361        <Box flexShrink={0}>
362          <Button key={`expand:${t.id}`} plain label={t.name} hover={tint} onPress={() => draw.toggleExpanded(t.id)} />
363        </Box>
364        <Box flexGrow={1} flexShrink={1} minWidth={0}>
365          {drawStatus(draw, t)}
366        </Box>
367        {hasMore && (
368          <Box flexShrink={0}>
369            <Button key={`more:${t.id}`} {...control} label={isOpened ? 'less' : 'more'} onPress={() => draw.toggleExpanded(t.id)} />
370          </Box>
371        )}
372        {!isRunning && (
373          <Box flexShrink={0}>
374            <Button key={`collapse:${t.id}`} {...control} label="hide" onPress={() => draw.toggleExpanded(t.id)} />
375          </Box>
376        )}
377        {Input !== undefined && (
378          <Box flexShrink={0}>
379            <Button key={`tell:${t.id}`} {...control} label={isTelling ? 'cancel' : 'tell'} onPress={() => draw.toggleTell(t.id)} />
380          </Box>
381        )}
382        {t.status !== 'stopped' && (
383          <Box flexShrink={0}>
384            <Button key={`stop:${t.id}`} {...control} label="stop" onPress={() => draw.stop(t.id)} />
385          </Box>
386        )}
387      </Box>
388      {drawSection(
389        draw,
390        'task',
391        [
392          <Text dimColor italic wrap={isOpened ? 'wrap' : 'truncate-end'}>
393            {isOpened ? t.task.trim() : task}
394          </Text>,
395        ],
396        gap,
397      )}
398      {noteLines.length > 0 && drawSection(draw, 'notes', noteLines, gap)}
399      {report !== false && drawSection(draw, 'report', [report], gap, tint)}
400      {queued.length > 0 && drawSection(draw, 'queued', queued, gap)}
401      {isTelling &&
402        drawSection(
403          draw,
404          'tell',
405          [
406            <Input
407              key={`${TELL_FIELD}${t.id}`}
408              placeholder={`a message for ${t.name}`}
409              submitLabel="send"
410              autoFocus
411              onSubmit={(value: string) => draw.tell(t.id, value)}
412            />,
413          ],
414          gap,
415          tint,
416        )}
417    </Box>
418  )
419}
420
hooks/band.tsx 216 lines
1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import type { Thought } from '../types'
4import { agoOf } from './pane'
5import { oneLine } from './prompts'
6
7// The band's dots, left of Multitask's dropdown: one for each running thought, in its
8// colour, in the order they started, DOTS_SHOWN at most and `+N` for the rest. A dot
9// pulses white and back as its thought writes a progress note. As a turn ends, the dot
10// flashes and gives way to a check (a cross for one that failed; a stopped one only
11// fades), which fades out of the row. Hovered on the desktop, a dot's card says the
12// thought's name, its latest note and how long ago that came.
13//
14// The desktop draws a dot as a small SVG, which it shows as an image: its SMIL animation
15// runs there, with no redraw. The desktop builds the band afresh at each redraw, which
16// would start an animation over, so one is drawn with how far it has got (a negative
17// `begin`): a redraw carries it on, and once it is over the dot is drawn still. The
18// terminal draws a dot as a glyph, inverted for a pulse; a check or cross as a turn ends.
19//
20// As pane.tsx: plain functions here, every hook and call on `$` in register.tsx.
21
22// The most dots the band shows; the rest are counted as `+N`.
23export const DOTS_SHOWN = 8
24// How long a dot's pulse takes, and its end.
25export const PULSE_MS = 1000
26export const FADE_MS = 1500
27// How often the ages in the dots' cards are drawn afresh while they stand.
28export const CARD_AGE_MS = 60_000
29
30// The key of a dot's Box: this, then the thought's id.
31export const DOT = 'dot:'
32
33// The side of a dot's picture, in CSS pixels: its disc 8 across, the rest room to flash in.
34const DOT_PX = 14
35const WHITE = '#ffffff'
36
37// What a dot shows: a running thought, or the end of its turn.
38type DotKind = 'running' | 'done' | 'failed' | 'stopped'
39type Dot = { t: Thought; kind: DotKind; since: number | undefined }
40
41// The terminal's glyph for each, the pane's for a failed or stopped thought.
42const GLYPH: Record<DotKind, string> = { running: '●', done: '✓', failed: '!', stopped: '×' }
43const WORD: Record<DotKind, string> = { running: 'running', done: 'done', failed: 'failed', stopped: 'stopped' }
44
45// What the dots are drawn with, as register.tsx reads it while drawing the band: the
46// surface and its elements, the time (none where it cannot be read: nothing animates or
47// fades, and the cards have no ages), and when each thought's turn was last seen to end
48// in this run, by id (a thought taken up from the store has none, so does not fade).
49export type DotsDrawing = {
50  surface: RenderSurface
51  ui: ElementTable
52  now: number | undefined
53  endings: ReadonlyMap<string, number>
54}
55
56// The thoughts with a dot, in the order they started: those running, and those whose
57// turn ended under FADE_MS ago.
58export function dotsOf(list: readonly Thought[], now: number | undefined, endings: ReadonlyMap<string, number>): Dot[] {
59  return list.flatMap((t): Dot[] => {
60    if (t.status === 'running') return [{ t, kind: 'running', since: t.notedAt }]
61    const at = endings.get(t.id)
62    if (at === undefined || now === undefined || now - at >= FADE_MS) return []
63    return [{ t, kind: t.status === 'idle' ? 'done' : t.status, since: at }]
64  })
65}
66
67// How far a dot's pulse or end has got, in ms, while it runs; undefined when none runs.
68function runningFor(d: Dot, now: number | undefined): number | undefined {
69  if (d.since === undefined || now === undefined) return undefined
70  const ms = now - d.since
71  return ms >= 0 && ms < (d.kind === 'running' ? PULSE_MS : FADE_MS) ? ms : undefined
72}
73
74// How long until the band must be drawn again for its dots: an end to fade out of the
75// row, a pulse on the terminal to end, the cards' ages; undefined when nothing waits.
76// A pulse on the desktop ends of itself.
77function redrawIn(dots: readonly Dot[], draw: DotsDrawing): number | undefined {
78  const { now } = draw
79  if (now === undefined || dots.length === 0) return undefined
80  const waits = dots.flatMap(d => {
81    const ms = runningFor(d, now)
82    if (ms === undefined || (d.kind === 'running' && draw.surface !== 'terminal')) return []
83    return [(d.kind === 'running' ? PULSE_MS : FADE_MS) - ms]
84  })
85  if (hasCards(draw)) waits.push(CARD_AGE_MS)
86  return waits.length === 0 ? undefined : Math.max(1, Math.min(...waits))
87}
88
89// Whether a dot has a card on hover: on the desktop, which lifts it over the band.
90const hasCards = (draw: DotsDrawing) => draw.surface === 'desktop'
91
92// A colour as it may go into a picture's markup: a thought's is one of the palette's; any
93// other is drawn grey.
94const markupColor = (color: string) => (/^#[0-9a-fA-F]{3,8}$/.test(color) ? color : '#888888')
95
96// One SMIL animation of `attribute` over the whole of a pulse or end (`ms`), through
97// `steps` of [ms from its start, value], `begin` ms from when the picture was drawn
98// (negative: under way already), held at its end. Each spans the whole, never ending
99// before the picture is drawn: one that had ended by then would not be held, as the
100// desktop's engine counts only an animation that ends after its picture began.
101function animate(attribute: string, ms: number, begin: number, steps: [number, string][], more = ''): string {
102  const values = steps.map(([, value]) => value).join(';')
103  const keyTimes = steps.map(([at]) => Math.round((at / ms) * 1000) / 1000).join(';')
104  return `<animate attributeName="${attribute}" values="${values}" keyTimes="${keyTimes}" dur="${ms}ms" begin="${begin}ms" fill="freeze"${more}/>`
105}
106
107const disc = (color: string, inner = '') =>
108  `<circle cx="7" cy="7" r="3.5" fill="${color}" stroke="${color}" stroke-width="1">${inner}</circle>`
109
110// A stroke of a turn's end (a check or cross): drawn in from its start after the flash,
111// then faded out by the end's close.
112const stroke = (color: string, path: string, length: number, begin: number) =>
113  `<path d="${path}" fill="none" stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" ` +
114  `stroke-dasharray="${length}" stroke-dashoffset="${length}">` +
115  animate('stroke-dashoffset', FADE_MS, begin, [[0, `${length}`], [300, `${length}`], [650, '0'], [FADE_MS, '0']]) +
116  animate('opacity', FADE_MS, begin, [[0, '1'], [FADE_MS - 500, '1'], [FADE_MS, '0']]) +
117  '</path>'
118
119// A dot's picture: still, pulsing, or its end, as far as it has got. Each animation's
120// start is marked in it, so two pulses are two pictures, never one the desktop held.
121export function pictureOf(d: Dot, now: number | undefined): string {
122  const color = markupColor(d.t.color)
123  const ms = runningFor(d, now)
124  const svg = (body: string) =>
125    `<svg xmlns="http://www.w3.org/2000/svg" width="${DOT_PX}" height="${DOT_PX}" viewBox="0 0 14 14">` +
126    `${ms === undefined ? '' : `<!--${d.since}-->`}${body}</svg>`
127  if (ms === undefined) return svg(d.kind === 'running' ? disc(color) : '')
128  const begin = -ms
129  if (d.kind === 'running') {
130    const ease = ' calcMode="spline" keySplines="0.42 0 0.58 1;0.42 0 0.58 1"'
131    return svg(disc(color, animate('fill', PULSE_MS, begin, [[0, color], [PULSE_MS / 2, WHITE], [PULSE_MS, color]], ease)))
132  }
133  const end = (attribute: string, steps: [number, string][]) => animate(attribute, FADE_MS, begin, steps)
134  if (d.kind === 'stopped') return svg(disc(color, end('r', [[0, '3.5'], [FADE_MS, '2']]) + end('opacity', [[0, '1'], [FADE_MS, '0']])))
135  // A flash: white, wider, gone; then the check or cross.
136  const flash = disc(
137    color,
138    end('fill', [[0, color], [150, WHITE], [FADE_MS, WHITE]]) +
139      end('r', [[0, '3.5'], [400, '6'], [FADE_MS, '6']]) +
140      end('stroke-width', [[0, '1'], [400, '0'], [FADE_MS, '0']]) +
141      end('opacity', [[0, '1'], [100, '1'], [400, '0'], [FADE_MS, '0']]),
142  )
143  const mark = d.kind === 'done' ? stroke(color, 'M4 7.3 6.2 9.5 10.2 4.8', 10, begin) : stroke(color, 'M4.5 4.5 9.5 9.5M9.5 4.5 4.5 9.5', 8, begin)
144  return svg(flash + mark)
145}
146
147// A dot's card, shown while it is hovered: the thought's name; its latest note and how
148// long ago that came, or, as its turn ends, its report and how it ended.
149function drawCard(draw: DotsDrawing, d: Dot): RenderElement {
150  const { Box, Text } = draw.ui
151  const { t } = d
152  const { now } = draw
153  const note = t.notes?.at(-1)
154  const isRunning = d.kind === 'running'
155  const said = isRunning ? note : t.lastText || note
156  const ago = (at: number | undefined) => (at === undefined || now === undefined ? undefined : agoOf(now - at))
157  const started = ago(t.startedAt)
158  const age = !isRunning ? WORD[d.kind] : note !== undefined ? ago(t.notedAt) : started === undefined ? undefined : `started ${started}`
159  return (
160    <Box position="absolute" display="none" hover={{ display: 'flex' }} flexDirection="column">
161      <Text color={t.color} bold>
162        {t.name}
163      </Text>
164      {said !== undefined && said !== '' ? <Text>{oneLine(said, 300)}</Text> : <Text dimColor>No notes yet</Text>}
165      {age !== undefined && <Text dimColor>{age}</Text>}
166    </Box>
167  )
168}
169
170// Whether the dots are pictures: everywhere but the terminal, whose table may still hand
171// over an Svg it cannot draw.
172const isPicture = (draw: DotsDrawing) => draw.surface !== 'terminal'
173
174// One dot, keyed by its thought: a picture with its card where the surface draws one,
175// else a glyph.
176function drawDot(draw: DotsDrawing, d: Dot): RenderElement {
177  const { ui } = draw
178  const { Box, Text } = ui
179  const key = `${DOT}${d.t.id}`
180  if (isPicture(draw) && 'Svg' in ui) {
181    const { Svg } = ui
182    return (
183      <Box key={key} flexShrink={0}>
184        <Svg source={pictureOf(d, draw.now)} alt={`${d.t.name}: ${WORD[d.kind]}`} width={DOT_PX} height={DOT_PX} />
185        {hasCards(draw) && drawCard(draw, d)}
186      </Box>
187    )
188  }
189  const isPulse = d.kind === 'running' && runningFor(d, draw.now) !== undefined
190  return (
191    <Box key={key} flexShrink={0}>
192      <Text color={d.t.color} {...(isPulse && { inverse: true })}>
193        {GLYPH[d.kind]}
194      </Text>
195    </Box>
196  )
197}
198
199// The band's dots, none when no thought has one, and how long until they must be drawn again.
200export function drawDots(draw: DotsDrawing, list: readonly Thought[]): { dots?: RenderElement; redrawIn?: number } {
201  const all = dotsOf(list, draw.now, draw.endings)
202  if (all.length === 0) return {}
203  const shown = all.slice(0, DOTS_SHOWN)
204  const { Box, Text } = draw.ui
205  const wait = redrawIn(shown, draw)
206  return {
207    dots: (
208      <Box flexDirection="row" alignItems="center" columnGap={isPicture(draw) ? 0 : 1} flexShrink={0}>
209        {shown.map(d => drawDot(draw, d))}
210        {all.length > shown.length && <Text dimColor>{`+${all.length - shown.length}`}</Text>}
211      </Box>
212    ),
213    ...(wait !== undefined && { redrawIn: wait }),
214  }
215}
216
types/index.d.ts 90 lines
1export type ThoughtStatus = 'running' | 'idle' | 'stopped' | 'failed'
2
3export type Thought = {
4  // the subagent's id, as $.agent.spawn answered it
5  id: string
6  name: string
7  task: string
8  color: string
9  status: ThoughtStatus
10  // messages the voice queued for when the thought finishes its current turn
11  queued: string[]
12  // the text of its latest finished turn: its report, question or problem
13  lastText: string
14  // its latest progress notes, oldest first, kept for following its work (each is relayed too)
15  notes: string[]
16  // whether it wrote a note after its latest report, which the Thoughts pane then no longer shows
17  notedSinceReport?: boolean
18  // the turn it is running now, from its latest model request
19  turnId: string | null
20  // a message to hand over once the interrupted turn has ended
21  interruptWith: string | null
22  // whether its queue waits no longer for a turn to end: the turn it waited for ended, and
23  // it goes to the thought at the next chance
24  isHandOverDue?: boolean
25  // when it was started, or its latest turn began; when it wrote its latest note; when its
26  // latest turn ended or it was stopped (ms, by $.clock). Absent for a thought kept before
27  // they were noted.
28  startedAt?: number
29  notedAt?: number
30  endedAt?: number
31}
32
33// What a thought said, waiting to reach the voice: a progress note, or the end of a
34// turn (isDone), with the text it ended on or none. A turn's end tells how many of the
35// voice's messages waited for it (pending), and whether they have reached the thought
36// since; a note about such messages still pending is isPending, dropped once they reach it.
37// isFromPerson: not the thought's but the person's own message to it, from the Thoughts pane.
38export type Update = {
39  thought: string
40  text: string
41  isDone?: boolean
42  pending?: number
43  isHandedOver?: boolean
44  isPending?: boolean
45  isFromPerson?: boolean
46}
47
48// A session's thoughts as kept in the store between runs of the app: when they were kept,
49// and the list, its oldest ended thoughts dropped past a bound.
50export type KeptThoughts = { at: number; thoughts: Thought[] }
51
52// What the voice was last told of a thought, by a relay or a what's-new note: its status,
53// its report and its latest progress note as they were then
54export type Heard = { status: ThoughtStatus; report: string; note: string }
55
56declare module 'claude-code' {
57  interface PluginState {
58    multitask: {
59      isOn: boolean
60      thoughts: Thought[]
61      inbox: Update[]
62      isMainBusy: boolean
63      // what the voice was last told of each thought, by thought id
64      heard: Record<string, Heard>
65      // whether the voice was last told, beside the person's message, that Multitask is on;
66      // false again after a compaction while on, so its whole part is told again
67      toldVoice: boolean
68      // thoughts the person opened in the Thoughts pane to show everything: a finished one's
69      // whole card instead of one line, a running one's whole task instead of its first line
70      expanded: string[]
71      // thoughts whose last report the person opened in the Thoughts pane to read whole,
72      // instead of its first line; a thought leaves it when it starts new work
73      openReports: string[]
74      // counts the minutes the Thoughts pane has stood open, so its ages are drawn afresh
75      paneTick: number
76      // counts the band's timed redraws: a thought's dot fading out as its turn ends, a pulse
77      // on the terminal ending, the ages in the dots' hover cards each minute
78      bandTick: number
79      // the thought whose card in the Thoughts pane has its field open to tell it something; '' none
80      tellingTo: string
81      // whether the Thoughts pane lists every thought that is not running, not only the
82      // most recently ended (the person pressed `N more`)
83      showsAllEnded: boolean
84      // where the turn through the palette's first colours stands: the index of the next a
85      // thought takes, unless a running thought holds it
86      colorTurn: number
87    }
88  }
89}
90