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

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.

# heading puts a message in its topic's thread: colour-coded, readable on its own and easy to find later.Examples: docs/why.md.
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.
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 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 while it is on and Focus while it is off; pick the other to switch./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.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, 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.# 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.+ thread (desktop app); to move it to another, press ▾ beside its thread tag.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.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./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./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.hooks/register.tsx 1538 lines1import { 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 lines1import 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}
192hooks/pane.tsx 420 lines1import 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}
420hooks/band.tsx 216 lines1import 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}
216types/index.d.ts 90 lines1export 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