Your plate from your Dazzer board in a pane beside the chat, in Claude Code: what needs you now, what waits on someone else, and what is coming up. It opens…

Makes your AI check what you already know before answering, and save what settles instead of losing it when the conversation ends.
Two lines in most tools. The same plugin, the same files — only the command changes.
/plugin marketplace add dazzer-io/dazzer-plugin
/plugin install dazzer@dazzer
Typed into Claude itself, not into your shell.
Using the desktop app? Install it from a terminal with the same two lines. The app and the terminal share one set of settings, so it appears in the app on your next conversation. The install command does not exist inside the app, so this genuinely needs a terminal once — even if you never otherwise open one.
codex plugin marketplace add dazzer-io/dazzer-plugin
codex plugin add dazzer@dazzer
Codex asks you to approve a plugin's reminders before it will run them. If nothing appears in the run history described below, that approval is the first thing to check.
copilot plugin marketplace add dazzer-io/dazzer-plugin
copilot plugin install dazzer@dazzer
mkdir -p ~/.copilot/instructions && cp ~/.copilot/installed-plugins/dazzer/dazzer/instructions/copilot.md ~/.copilot/instructions/dazzer.instructions.md
The third line is not patching a bad install. Copilot gives a reminder no way to speak, so the check-your-Brain sentence is delivered as a standing instruction instead — Copilot reads it before every conversation. Re-run that third line after updating the plugin, because it is a copy: Copilot ignores a link.
The other two reminders still do not arrive on Copilot — see below for why.
It has no store to install from, so point it at a downloaded copy:
git clone https://github.com/dazzer-io/dazzer-plugin.git
agy plugin install ./dazzer-plugin/plugins/dazzer
agy plugin install ./dazzer-plugin/plugins/dazzer-antigravity
Two installs here rather than one, and it is not tidiness — Antigravity cannot read the first one's reminders file, so the second carries them. See below for why.
Two of the three reminders arrive — checking your Brain before answering, and saving what settled at the end of a reply. Being put back on track after a long conversation forgets itself never can: that moment does not exist in this tool at all.
Two steps, and the second one happens inside Cursor rather than in a terminal.
cursor-agent plugin marketplace add https://github.com/dazzer-io/dazzer-plugin.git
Then, in Cursor, type /add-plugin and choose dazzer from the list.
Two things that cost real time when this was first tried, so they are written down here:
cursor-agent plugin marketplace remove dazzer then add it again.One difference you will see rather than read about: Cursor offers no moment for a plugin to speak privately to the AI, so the save prompt cannot be moved off your screen the way it has been everywhere else. It only accepts a message and submits it as though you had typed it, so on Cursor the save prompt is visible in your conversation. Everything works; it just is not silent.
devin plugins install dazzer-io/dazzer-plugin#plugins/dazzer
Two of the three reminders work. The save prompt does not, and cannot: Devin tells a reminder only which session and which prompt it is, with no way to tell how much conversation has built up since last time. The prompt has nothing to measure, so it stays quiet rather than firing blindly.
Everything above adds the reminders. They do not reach your Brain by themselves. A tool with the reminders and no connection has an AI being told to check something it cannot open, which is worse than having neither: it goes looking, and it finds whatever else it has.
Run this whether or not you already have Dazzer. It used to say the opposite — skip it if Dazzer already shows as connected — and that one line stranded people. Somebody already installed skips it, nothing ever asks them to sign in, and they end up with an AI told to check a Brain it cannot open. Running it again is also how you get the current version.
You will be asked to sign in afterwards. That is the point rather than a cost: installing does not sign you in on any tool, and the sign-in is the part that was missing.
One line per tool, in that tool's own wording.
Claude Code — typed into Claude itself:
/plugin install dazzer-connect@dazzer
Codex — in a terminal:
codex plugin add dazzer-connect@dazzer
GitHub Copilot — in a terminal:
copilot plugin install dazzer-connect@dazzer
Antigravity — in a terminal, against the copy you downloaded:
agy plugin install ./dazzer-plugin/plugins/dazzer-connect
Cursor — type /add-plugin in Cursor and choose dazzer-connect.
Devin — in a terminal:
devin plugins install dazzer-io/dazzer-plugin#plugins/dazzer-connect
Installing does not sign you in. On any tool. All six were walked on one afternoon and not one of them reliably signs you in as part of the install. It happens the next time the tool starts up and reaches out.
So close your tool, start it again, and sign in when it asks. That is the step, and it is the same step everywhere. Where a tool needs a command as well, it is under its name below.
Claude Code — the sign-in appears during the install only on a machine that has never had Dazzer. On every other machine the install is silent and nothing ever asks. If starting it again does not prompt you, open the sign-in list yourself with /mcp and choose dazzer.
Codex — nothing appears on its own. Do it up front rather than waiting for the first thing that reaches your Brain to fail quietly:
codex mcp login dazzer
GitHub Copilot — the sign-in appeared the next time the tool was started. Nothing during the install.
Devin — it signed itself in without asking and worked. If yours does ask, this is when.
Antigravity — not established. It has been watched failing to reach the Brain at all, which is the case to check for: ask it what your Brain knows about you, and if the answer is anything other than real records, it is not signed in.
Cursor — not established.
Whoever you are already signed in as is who it signs in as. Nothing asks and nothing warns. If you are testing with a different account, sign out of Dazzer in your browser first, or the memories land under the wrong person and the dashboard you are watching never shows them.
How to tell it worked, on any tool: ask your AI what your Brain already knows about you. Real records back means it is signed in. Anything else — a shrug, a file it found, a summary of your own conversation — means it is not, whatever the install said.
Four of the six above are now watched rather than read about: Claude Code, Codex, Copilot and Devin. Antigravity and Cursor are not, and say so where a person can see it.
This section existed in one wording only, Claude's, and every other tool's setup simply ended after the reminders. Someone followed the Codex instructions to the letter on a clean machine, was never asked to sign in because there was nothing to sign in to, and watched his AI go hunting through an unrelated archive for the memory it had just been told to check. The command it needed existed and worked the whole time. Nobody had written it down, and no rule asked whether anything was missing — every rule here compared what we say against what we ship, and both said the same thing. A rule that asks the missing question now exists.
Nothing updates on its own. Sources like this one have automatic updating switched off by default — that is the tool makers' own default, not a choice of ours — so whatever version you first installed is the version you keep, indefinitely. A machine was found sitting several versions behind while reporting itself perfectly healthy.
Claude Code — every line, in this order, then restart Claude Code:
claude plugin marketplace update dazzer
claude plugin update dazzer@dazzer
claude plugin update dazzer-connect@dazzer
claude plugin update dazzer-plate@dazzer
The first refreshes the list of what exists. The lines after it are what actually move the version you are running, and they are what people were missing.
The first line on its own does nothing to the plugin you are using, and says otherwise. It prints ✔ Successfully updated marketplace: dazzer and leaves you on exactly the version you were already on. That was watched happening here: marketplace refreshed to the newest commit, plugin still reporting the previous version, old behaviour intact after a restart. A success message that means "the catalogue is current", read as "you are current", is the whole trap. Only claude plugin update changes the answer, and only a restart applies it.
Drop the third line if you never installed the connection separately, and the fourth if you never installed the plate pane (below).
To stop doing this by hand: open the plugin panel with /plugin, go to Marketplaces, choose dazzer, and select Enable auto-update. It keeps itself current after that.
Antigravity — it runs from a copy you downloaded, so refresh the copy and install again:
git -C dazzer-plugin pull
Then re-run the three install lines above against it.
Cursor — its update command reports success without actually re-fetching, so removing and adding again is the only thing that gets you a newer version:
cursor-agent plugin marketplace remove dazzer
Then add it again and install, as above.
Codex, GitHub Copilot and Devin — not established. Nothing here has been run on those three, so there is no step to give you rather than a guess dressed up as one.
Whatever the tool, removing the source and adding it back is the fallback that always re-fetches — at the cost of signing you out, so it is the last resort rather than the first.
Claude's chat apps and the web app have no moment at which anything can run. There the bundled skill carries the same intent as plain text, and reaching your Brain works exactly the same.
One file cannot serve every tool, and finding that out cost a release where Claude Code loaded none of this at all. It checks every moment-name against a fixed list and throws out the whole file over a single name it does not know — and the shared file carried five belonging to other tools. It reported failed to load and registered nothing. Nobody noticed, because the reminders people saw came from their own settings rather than from here.
Every tool does this. Claude Code says so out loud; the rest just go quiet. So each now reads a file holding its own moments and nothing else:
| File | Read by |
|---|---|
hooks/hooks.json | Claude Code and Codex — the three moments they both answer |
hooks/cursor.json | Cursor, which takes a file its own listing names in preference to the shared one, and refuses one without a whole-number version — a key the shared file must not carry, because Codex refuses a file over it |
hooks.json | Devin — at the plugin root, which Claude Code does not read at all |
That last row is load-bearing and was established by experiment: a name Claude Code rejects was put in the root file, and the plugin still loaded.
The reason recorded here before — that Copilot's moments had to share the file Claude Code reads — was wrong, and it was wrong in a way worth keeping on the record, because it stood unchallenged while it made a supported tool look unsupported. Copilot takes a file of its own without complaint. Its moments even have different names from Claude Code's, so sharing was never going to work.
What is true was found by running it, not by reading about it. A reminder runs on Copilot and has no way to say anything to the model. Both moments fired and left their mark on disk; five different shapes of reply were tried in one go, including the exact shapes Cursor and Claude Code use, and the model reported receiving nothing every time.
Two other routes were tried and one of them works:
~/.copilot/instructions/*.instructions.md before every conversation. With our one sentence there, asking a question the Brain could answer made the model go and ask it, unprompted. That is the third install line above.So Copilot gets the check-your-Brain reminder, delivered a different way. It does not get the other two, and cannot: saving what settled, and getting back on track after a reset, each need to speak at a particular moment, and speaking is the half Copilot withholds.
It is a copy, not a link — a link was tried and Copilot ignored it. So an update to the plugin does not reach the instruction until that line is run again.
The file has to instruct, not remind, and this was nearly missed. The first version shipped opened by explaining to a human reader why reminders cannot work on this tool. The model reads that file as its own standing instruction, explanation and all — so it was being told, in effect, that Dazzer's prompting does not work here, and then asked to act on Dazzer's prompt.
The difference is not subtle and is not a matter of taste. Asked the same question six times, with the connection attached and a Brain that could answer it, the explaining version made the model go and ask none of those six times. Rewritten as a plain instruction that names what to call and when, it asked all six.
Two things follow, and the second is the general one. Anything put in that file is read by the model, so commentary belongs here in the README and never there. And a wording change like this must be measured over several runs, never one — the first version was called working on the strength of a single successful attempt, and a single attempt cannot tell a rule that is obeyed from one that is obeyed sometimes.
The reason recorded here before — that a separate file was tried and Antigravity's own importer overwrote it — was wrong, and it kept a working capability shut off. Antigravity has a documented way to carry reminders, its own moments fire, and what a reminder says there does reach the model: asked to quote the sentence back, it did, word for word.
What is actually in the way is a name collision with Devin. Both look for a file called hooks.json at the plugin root, and they want different shapes inside it. Antigravity discards the whole file over one entry it does not recognise — the same behaviour as Claude Code. Proven by putting both shapes in one file and watching every reminder vanish.
So Antigravity's reminders ship as a second small plugin of their own, plugins/dazzer-antigravity, which is the extra install line above. Two further things were proven rather than assumed: given its own file it works, and a broken neighbour sitting beside it does not drag it down — the failing mixed file was left in place deliberately while the good one was tested.
Saving what settled arrives too, and it needed one thing built. Antigravity never says whether a reply is the one our own save prompt asked for — every other tool says so outright. Leaning on the amount of new work instead was tried and measured wrong: with the prompt made deliberately eager it fired five times in a single reply and the answer repeated five times.
So where the host will not say it, the prompt now says it to itself: it leaves a note, and the very next end-of-reply in that conversation reads the note, clears it, and stays quiet. One note, consumed once, so a prompt can never answer its own prompt. The cost is that a genuine second reply straight after a save is skipped — the safe direction, since the backstop fires a little less often rather than twice. Three tests hold that note's whole life: left, read once, and gone.
Devin's limit above was found by running a real session and capturing exactly what it hands a reminder — not by reading its documentation, which describes a richer message than the one that actually arrives.
Watching it work was not enough, and it is worth saying exactly how it failed. Watching proved a person saw the plugin do something. It did not prove the reminder's words ever reached the AI. On Codex they never did: one unexpected line made it throw the whole reminders file away, and it wants a reply in a particular shape, so plain words were discarded and reported as an error on every single message. On Cursor they never did either, and Cursor said nothing at all about it. On Claude Code the whole plugin never loaded.
Then the bar was raised again, twice, because each version proved too little. Showing the right words reach the AI from a hand-written file says nothing about whether the shipped file loads — that gap is exactly how the Claude Code failure hid behind a passing test. And a tool reporting its own reminders as registered proves nothing either: it says so while delivering nothing at all. What counts is two things and no others — install the real thing, then ask the AI for a word only the reminder could have carried.
Against that bar: Claude Code passes — installed from a listing, it answered back a word only the reminder carried. Codex runs every reminder from an installed copy with no warning and no error. Cursor answers it too, from an installed plugin reading the file of its own it moved to. Devin takes the plain wording it already gets.
Cursor was the last, and it is worth saying why it took two attempts. This repository briefly shipped a Cursor-specific listing of what it contains; Cursor preferred that file over the one it had been reading happily, could not make sense of it, and silently stopped finding the plugin at all. Nothing reported an error — the install simply handed out a months-old version instead. The file has been removed. Reading a tool's documentation was not enough here, and it was not enough for two of the others either.
A pane that shows your plate from your Dazzer board: what needs you now, what is waiting on someone else, what is coming up, and how many things can wait. It is Claude Code only, and a plugin of its own, so nobody gets a pane they did not ask for.
/plugin install dazzer-plate@dazzer
Typed into Claude itself, after the two Claude Code lines above. On the desktop app, install it from a terminal the same way; the app picks it up on your next conversation.
Then type /plate. Nothing opens by itself: the pane draws only once you ask, and its Refresh button reads your plate again. At the top: the day, and one line of counts. Below it, four tabs: Needs you, Waiting, Coming up and Later, each with its count. Each thing on a tab is a card: a coloured tag saying why it is there (8 DAYS LATE, WAITING ON YOU · 6 DAYS, STARTED, ON GAL · 65 DAYS, DUE SAT 10 OCT, with (A GUESS) where Dazzer read it from the words and is unsure), its title as it was written, and one line saying what it is part of, who it is from and when it last moved, ending maybe yours (a guess) where Dazzer is unsure it is yours to do. Open shows what it is, and two buttons:
Mark item 7240 done.). Your AI marks it done, and the pane updates on the next plate it sees. The card says it was sent only once your session took the sentence, and keeps saying so until a plate arrives without that item; if it was not taken, it says so and offers Done again.Tell me about item 7240., so you and your AI can talk it through. It sends once for that item until the pane reads your plate again, so a double click never sends it twice.The title never goes with either: anyone in your workspace can write a title, and those sentences go as your own words.
Later lists what can wait, grouped by what each thing belongs to, each with its count: the 30 largest groups, then how many more groups there are. Open a group to see its items and when each last moved. Once read, a group stays read until you press Refresh, which forgets every group, or until the pane reads your plate again and that group's count has changed; then the next Open reads it again. Until your Dazzer board sends those groups, Later shows how many things can wait.
It needs Dazzer connected (see Reaching your Brain above): it reads through the connection Claude Code already has and brings none of its own. It looks only among the tools your session has right now, and only at a server offering both of Dazzer's recall and track, so your words, your time zone and the pane's name never go to any other server. When it finds none, the pane says Dazzer was not found here and how to connect it. A read that has not answered in 20 seconds says it could not reach Dazzer, rather than waiting forever.
In auto mode, /plate, Refresh and opening a later group put one question and your AI's answer in your chat. Where Claude Code lets the pane read your plate itself, nothing appears in your chat. In auto mode it does not, so /plate and Refresh each try the read first and, when it is refused, put one question to your AI, as your own words: What is on my plate? My time zone is <your time zone>. Opening a later group does the same for that group: Show my later items in plate group <its number>. Its name never goes with it, for the same reason a title never does. The pane draws what your AI reads. One question at a time: while one is unanswered, the pane sends nothing more and says it is waiting on your AI. If your AI is busy when the pane asks, the question waits its turn behind the current reply and anything you typed meanwhile, and the pane keeps saying it asked until the question's own turn ends. If that question never runs (you pressed Esc), the pane lets your next press ask again once your AI has been idle for 10 seconds.
Nothing else ever posts in your name. Only /plate, Refresh, opening a later group, and a card's Done and Talk about it do, and only when you use them.
What it keeps: the last plate it read, and each later group it read (until the next Refresh, or until that group's count changes), in this session's memory only, so a read that fails can still show your plate with the time it was read. Nothing is saved to disk, and nothing is shared with another session or another account.
To keep /plate from being offered at all, set its Plate pane option to off in /config.
What is proven: its tests draw every state on both the terminal and the desktop app. Watched live in the desktop app in auto mode,
hooks/plate.tsx 1587 lines1// The plate pane: a person's plate from their Dazzer board, drawn beside the chat when they ask.
2//
3// WHEN IT DRAWS. Only once the person asks: /plate, or the pane's own refresh once it is open.
4// Nothing here opens a pane, reads the board or sets a status line from the session's start, a
5// timer or anything the AI does on its own; until the person asks, the pane's state is "unasked"
6// and it draws nothing. It only notes which main-loop turn is running, so that a question it puts
7// later is never ended by the turn it was queued behind.
8//
9// WHAT IT DRAWS (the design approved on 8 Oct, parts/where-it-shows.md section 8). The day and one
10// line of counts; four tabs, one per group, the chosen one the primary button; a bordered card per
11// row, lit on hover, with a coloured tag read from the row's own fields (never from the plain
12// words), its title, and one dim line of what it is part of, who it is from and when it last
13// moved. A card opens in place to what it is, Done and Talk about it. Later is its groups, each
14// opening to its items' titles and when each last moved. An answer from a board without those
15// fields (no part, moved, about or later groups) still draws every tag, and later as a count.
16//
17// HOW IT READS. It holds no connection and no credentials. It finds the board among the tools the
18// session has connected now: a server offering both `recall` and `track`, so the person's words,
19// zone and conversation name never go to a server that is not a board. It calls that server's
20// `recall` for the plate view through the session's own connection (`$.mcp.call`); the first whose
21// answer holds a plate is the board. Opening a later group reads that group (`part`) from the board
22// the plate came from, the same way. Every read ends within 20 seconds, in what it read or in one
23// failed line, never in an endless "Reading".
24//
25// WHERE THE ENGINE WILL NOT LET IT. In auto mode the engine's classifier refuses the pane's own
26// call (seen live in the Desktop app). Only for a read the person started (/plate, Refresh, or
27// opening a later group), and only when the auto mode classifier refused it, the pane puts one
28// question to the person's AI, as the person's own words: "What is on my plate? My time zone is
29// <the machine's zone>." for the plate, "Show my later items in plate group <part>." for a group.
30// When the AI's own recall returns that plate or that group, the pane draws it. One question
31// at a time, across the plate and its groups: while one is unanswered, every read the person starts
32// still tries the board and sends nothing more, and the pane says it waits on the AI. The question
33// ends with its own turn, or failing that the first turn to end after the session took it; if
34// nothing came, the pane says so. Put while another turn is running, it waits behind that turn:
35// that turn's end does not end it. The turn opening with its own words is its turn; a turn opening
36// with other words may be it reworded, or a turn queued ahead of it (a message the person typed, a
37// task notification), so it holds the question only tentatively, and the question ends after such
38// a turn only once no turn has started for 10 seconds. A question that never runs (its queued turn
39// cancelled) is lost once the session has been idle for 10 seconds since the turn it waited
40// behind: the person's next press may ask again. Any other refusal of the engine's (a deny rule, don't-ask mode, a
41// hook) is not cured by asking: the pane says Claude Code does not let it read here, and asks
42// nothing. Every read the person starts tries the board first, so a change of mode takes effect at
43// once.
44//
45// WHAT NEVER POSTS. Nothing but the person's own presses ever submits anything in their name:
46// /plate and Refresh (the plate's question), opening a later group (its question), and a card's
47// Done and Talk about it. The AI's track (a subagent's included), its own reads, a timer and the
48// session's start never do. After the AI's own call to the board, the pane only reads the board
49// again itself, and not at all once the engine has refused it in this session (so refusals never
50// pile up), nor while a read the person started is still on its way; refused, it keeps what it
51// shows and asks no one.
52//
53// WHO WRITES. Never this pane. A card's Done sends the person's AI one sentence carrying the item's
54// number and nothing else ("Mark item 7236 done."); Talk about it sends "Tell me about item 7236.".
55// The AI acts through its own connection. A title never travels with either, nor a group's name
56// with its question: anyone in the workspace can write those, and these sentences speak as the
57// person. A press counts as sent
58// only once the session took that sentence; a sent Done stays sent until a plate arrives without
59// that item.
60//
61// WHAT IT KEEPS. The last plate this session read, and each later group it read, in the session's
62// own state (`$.state`), so a failed read can still show the plate with its time and a group opened
63// again is not read again: until the next Refresh, which forgets every group's rows, or a plate
64// arriving with that group's count changed. Nothing is written to disk and nothing is shared with another session,
65// so a failed read never shows anyone else's plate.
66//
67// WHOSE WORDS. Titles, what a row is, the names of what it is part of, and the members it names
68// are other people's words: each passes through the same one neutralising pass the board applies,
69// and is only ever plain text, never a link or a control.
70
71import { atom, read, update } from 'claude-code'
72import type { EngineInterface, McpToolResult, Register, ToolCallResult, ToolInfo } from 'claude-code'
73
74import type {
75 PlateAsk,
76 PlateCounts,
77 PlateGroupView,
78 PlateLaterGroup,
79 PlateLaterRow,
80 PlatePart,
81 PlateQuestion,
82 PlateReply,
83 PlateRow,
84 PlateTab,
85 PlateView,
86} from '../types'
87
88/** The one pane, by id and title. */
89const PANE = 'plate'
90const TITLE = 'Your plate'
91/** What the pane asks the board, in the person's own words, and the conversation it names. */
92const WORDS = 'what is on my plate'
93const GROUP_WORDS = 'what can wait until later'
94const CONVERSATION = 'plate-pane'
95/** How long one read may take before the pane says it could not reach Dazzer. */
96const READ_LIMIT_MS = 20_000
97/**
98 * How long with no turn starting before a question that is not running counts as not going to: one
99 * held only tentatively by a turn that has ended, or one queued behind a turn that has ended.
100 */
101const QUIET_MS = 10_000
102/** A connected server's recall tool, as the session names it. */
103const RECALL = /^mcp__(.+)__recall$/
104const BACKSLASH = '\x5c'
105/** Between the pieces of one line: the counts, a tag's parts. */
106const DOT = ' \u{b7} '
107
108/**
109 * The engine's own words when it refuses a call (read from Claude Code 2.1.293 itself). Nothing a
110 * server says matches these, so a server's "Access denied" stays a failure to reach the board.
111 * The auto mode classifier's refusal is the one the person's own request cures: only it asks the
112 * AI. The engine's other refusals (a deny rule, don't-ask mode, a hook) are not, and ask nothing.
113 */
114const CLASSIFIER_REFUSAL = /auto mode classifier/i
115const ENGINE_REFUSALS = [
116 /\bPermission for this (?:action|tool use) (?:was|has been) denied\b/,
117 /\bPermission to use \S+ has been denied\b/,
118 /\bPermission denied by (?:PermissionRequest )?hook\b/,
119]
120
121/** The tabs, in the order a person reads them, named by what they ask of the person. */
122const TABS: readonly { key: PlateTab; name: string }[] = [
123 { key: 'now', name: 'Needs you' },
124 { key: 'waiting', name: 'Waiting' },
125 { key: 'coming', name: 'Coming up' },
126 { key: 'later', name: 'Later' },
127]
128const ACTIVE = ['now', 'waiting', 'coming'] as const
129
130const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
131const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
132
133/** The line that says how to connect, as the README's connection step says it. */
134const CONNECT =
135 'To connect it, type /plugin install dazzer-connect@dazzer in Claude Code in a terminal, then restart and sign in. Already connected? Try Refresh.'
136
137const view = atom({ plugin: 'dazzer-plate', key: 'view' } as const, { kind: 'unasked' } as PlateView)
138const asked = atom({ plugin: 'dazzer-plate', key: 'asked' } as const, {} as Record<string, PlateAsk>)
139const talks = atom({ plugin: 'dazzer-plate', key: 'talks' } as const, {} as Record<string, PlateAsk>)
140const boards = atom({ plugin: 'dazzer-plate', key: 'boards' } as const, [] as string[])
141const source = atom({ plugin: 'dazzer-plate', key: 'source' } as const, null as string | null)
142const question = atom({ plugin: 'dazzer-plate', key: 'question' } as const, { state: 'none' } as PlateQuestion)
143const refusedHere = atom({ plugin: 'dazzer-plate', key: 'refusedHere' } as const, false)
144const runningTurn = atom({ plugin: 'dazzer-plate', key: 'runningTurn' } as const, null as string | null)
145const tab = atom({ plugin: 'dazzer-plate', key: 'tab' } as const, 'now' as PlateTab)
146const openCard = atom({ plugin: 'dazzer-plate', key: 'openCard' } as const, null as number | null)
147const openGroup = atom({ plugin: 'dazzer-plate', key: 'openGroup' } as const, null as string | null)
148const groups = atom({ plugin: 'dazzer-plate', key: 'groups' } as const, {} as Record<string, PlateGroupView>)
149const plateHeld = atom({ plugin: 'dazzer-plate', key: 'plateHeld' } as const, false)
150
151/** The newest plate read; an older one that answers late changes nothing. */
152let newestRead = 0
153/** Plate reads the person started that are still on their way; a track's read never overtakes one. */
154let personReads = 0
155/** The newest read of each later group, by its key; an older one that answers late changes nothing. */
156const newestGroupRead = new Map<string, number>()
157let groupReadsMade = 0
158/** Main-loop turns started this session, so a later check can tell whether one has started since. */
159let turnsStarted = 0
160/** Questions put this session: each question's own number. */
161let questionsPut = 0
162
163/** The line the pane shows when the engine refuses it in a way asking cannot cure. */
164const NOT_ALLOWED = 'Claude Code does not let the pane read your plate here.'
165
166/**
167 * The board's one neutralising pass on someone else's words (its `neutralizeText`): a broken
168 * character half becomes the replacement character, a line break or control character a space, a
169 * direction override or hidden tag character goes, and a backtick becomes an apostrophe. A title
170 * can bend its own row and nothing more.
171 */
172function neutral(value: string): string {
173 return value
174 .replace(/[\u{d800}-\u{dfff}]/gu, '\u{fffd}')
175 .replace(/[\x00-\x1f\x7f-\x9f\u{2028}\u{2029}]/gu, ' ')
176 .replace(/[\u{202a}-\u{202e}\u{2066}-\u{2069}]/gu, '')
177 .replace(/[\u{e0000}-\u{e007f}\u{e0100}-\u{e01ef}]/gu, '')
178 .replace(/`/g, "'")
179}
180
181/** Someone else's words on one line: neutralised, every run of spaces one space, trimmed. */
182const oneLine = (value: string) => neutral(value).replace(/\s+/g, ' ').trim()
183
184const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
185const isDay = (value: unknown): value is string => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
186/** A string with something in it, else nothing. */
187const textOf = (value: unknown): string | undefined =>
188 typeof value === 'string' && value.trim() !== '' ? value : undefined
189
190/** The machine's own time zone, so the board counts the person's own day; none when unknown. */
191function machineZone(): string | undefined {
192 const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
193 return typeof zone === 'string' && zone.length > 0 ? zone : undefined
194}
195
196/** The question the pane puts to the person's AI for the plate, in the person's own words. */
197function theQuestion(): string {
198 const zone = machineZone()
199 return zone === undefined ? 'What is on my plate?' : `What is on my plate? My time zone is ${zone}.`
200}
201
202/** The servers offering both recall and track, each once, in the order the session lists them. */
203function boardsAmong(tools: readonly ToolInfo[]): string[] {
204 const names = new Set(tools.filter(tool => tool.mcp).map(tool => tool.name))
205 const found: string[] = []
206 for (const name of names) {
207 const server = RECALL.exec(name)?.[1]
208 if (server !== undefined && names.has(`mcp__${server}__track`) && !found.includes(server)) found.push(server)
209 }
210 return found
211}
212
213const isRow = (row: unknown): row is PlateRow =>
214 row !== null &&
215 typeof row === 'object' &&
216 typeof (row as PlateRow).id === 'number' &&
217 typeof (row as PlateRow).title === 'string'
218
219const isLaterRow = (row: unknown): row is PlateLaterRow =>
220 row !== null &&
221 typeof row === 'object' &&
222 typeof (row as PlateLaterRow).id === 'number' &&
223 typeof (row as PlateLaterRow).title === 'string'
224
225/** What a row is part of, when the reply says so in a shape the pane can read. */
226function partOf(value: unknown): PlatePart | undefined {
227 if (value === null || typeof value !== 'object') return undefined
228 const part = value as { id?: unknown; name?: unknown; unfiled?: unknown }
229 // What is not filed yet has no number and no name: known by its mark, read first.
230 if (part.unfiled === true) return { id: null, name: null, unfiled: true }
231 if (!isNumber(part.id)) return undefined
232 const name = textOf(part.name)
233 return name === undefined ? undefined : { id: part.id, name }
234}
235
236/** One later group, when the reply says it in a shape the pane can read. */
237function laterGroupOf(value: unknown): PlateLaterGroup | undefined {
238 if (value === null || typeof value !== 'object') return undefined
239 const group = value as Partial<PlateLaterGroup>
240 if (!isNumber(group.count) || group.count < 0) return undefined
241 if (group.unfiled === true) return { id: null, name: null, unfiled: true, count: group.count }
242 if (group.id === null) return { id: null, name: null, count: group.count }
243 if (!isNumber(group.id)) return undefined
244 return { id: group.id, name: textOf(group.name) ?? null, count: group.count }
245}
246
247/** A later group's key: its number, `unfiled`, or `none`; what `part` asks the board for. */
248const keyOf = (group: PlateLaterGroup) => (group.unfiled === true ? 'unfiled' : group.id === null ? 'none' : String(group.id))
249const partOfKey = (key: string): number | string => (key === 'unfiled' || key === 'none' ? key : Number(key))
250
251/** The key of the group a call asked for by `part`, as recall reads it; none when unreadable. */
252function keyOfAsked(part: unknown): string | undefined {
253 if (isNumber(part)) return String(part)
254 if (typeof part !== 'string') return undefined
255 const word = part.trim().toLowerCase()
256 if (word === 'unfiled' || word === 'none') return word
257 return /^\d{1,15}$/.test(word) ? String(Number(word)) : undefined
258}
259
260/** A later group's name as the pane shows it. Its question never carries it. */
261function groupName(group: PlateLaterGroup): string {
262 if (group.unfiled === true) return 'Not filed yet'
263 if (group.id === null) return 'Not part of anything'
264 return group.name === null ? 'No name' : oneLine(group.name)
265}
266
267/**
268 * The question for one later group, in the person's own words: its number, `unfiled` or `none`,
269 * and nothing anyone else wrote. A group's name is someone else's words, as a title is.
270 */
271const groupQuestion = (group: PlateLaterGroup) => `Show my later items in plate group ${keyOf(group)}.`
272
273/** What a plate-view reply holds: a plate look, or the read of one later group. */
274type Answer =
275 | { kind: 'plate'; plate: PlateReply }
276 | { kind: 'group'; key: string | undefined; rows: PlateLaterRow[]; count: number }
277
278/** What a value answers, rows that are not rows left out; undefined when it is no plate reply. */
279function answerOf(body: unknown): Answer | undefined {
280 if (body === null || typeof body !== 'object') return undefined
281 const plate = body as Partial<PlateReply>
282 const counts = plate.counts as Record<string, unknown> | undefined
283 const isPlate =
284 plate.view === 'plate' &&
285 counts !== undefined &&
286 counts !== null &&
287 ['now', 'waiting', 'coming', 'later'].every(key => typeof counts[key] === 'number') &&
288 Array.isArray(plate.now) &&
289 Array.isArray(plate.waiting) &&
290 Array.isArray(plate.coming)
291 if (!isPlate) return undefined
292 const whole = plate as PlateReply
293 const listed = Array.isArray(whole.later_groups)
294 ? whole.later_groups.map(laterGroupOf).filter((group): group is PlateLaterGroup => group !== undefined)
295 : undefined
296 if (Array.isArray(whole.later)) {
297 const rows = whole.later.filter(isLaterRow)
298 const group = listed?.[0]
299 return { kind: 'group', key: group === undefined ? undefined : keyOf(group), rows, count: group?.count ?? rows.length }
300 }
301 const people = whole.people !== null && typeof whole.people === 'object' ? whole.people : {}
302 return {
303 kind: 'plate',
304 plate: {
305 ...whole,
306 now: whole.now.filter(isRow),
307 waiting: whole.waiting.filter(isRow),
308 coming: whole.coming.filter(isRow),
309 people,
310 // Absent from today's server: later is then a count, with no groups to open.
311 later_groups: listed,
312 },
313 }
314}
315
316/**
317 * Every JSON object written at the top level of a text, in order, whatever lies between them:
318 * how the AI's own reply reads once its blocks are joined.
319 */
320function objectsIn(text: string): unknown[] {
321 const found: unknown[] = []
322 let depth = 0
323 let start = -1
324 let isInString = false
325 let isEscaped = false
326 for (let at = 0; at < text.length; at++) {
327 const char = text[at]
328 if (isInString) {
329 if (isEscaped) isEscaped = false
330 else if (char === BACKSLASH) isEscaped = true
331 else if (char === '"') isInString = false
332 continue
333 }
334 if (char === '"' && depth > 0) isInString = true
335 else if (char === '{') {
336 if (depth === 0) start = at
337 depth += 1
338 } else if (char === '}' && depth > 0) {
339 depth -= 1
340 if (depth === 0) {
341 try {
342 found.push(JSON.parse(text.slice(start, at + 1)))
343 } catch {
344 // Not JSON after all; the next object may be.
345 }
346 }
347 }
348 }
349 return found
350}
351
352/** The first plate reply among some texts, each read on its own. */
353function answerAmong(texts: readonly string[]): Answer | undefined {
354 for (const text of texts) {
355 for (const body of objectsIn(text)) {
356 const answer = answerOf(body)
357 if (answer !== undefined) return answer
358 }
359 }
360 return undefined
361}
362
363/** A reply's text blocks, each as its own text. */
364const blocksOf = (result: McpToolResult): string[] =>
365 result.content.flatMap(block => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
366
367/** The plate reply in the AI's own recall: the reply as it read it, else the reply's blocks. */
368function answerInCall(ran: ToolCallResult): Answer | undefined {
369 if (ran.deny !== undefined || ran.isError === true) return undefined
370 const fromText = typeof ran.text === 'string' ? answerAmong([ran.text]) : undefined
371 if (fromText !== undefined) return fromText
372 const result = ran.result as Partial<McpToolResult> | undefined
373 return Array.isArray(result?.content) ? answerAmong(blocksOf(result as McpToolResult)) : undefined
374}
375
376/**
377 * Whether a refusal names the plate: the plate view refused, or the plate not switched on. The
378 * plate as a word of its own, since a refused call's words carry this plugin's name too.
379 */
380const namesThePlate = (words: string) => /(^|[^\w-])plate\b/i.test(words)
381
382/**
383 * How the engine refused a call that threw, in its own words: by the auto mode classifier, which
384 * the person's own request cures; otherwise, which it does not; or not at all.
385 */
386function refusalOf(words: string): 'classifier' | 'engine' | undefined {
387 if (CLASSIFIER_REFUSAL.test(words)) return 'classifier'
388 return ENGINE_REFUSALS.some(pattern => pattern.test(words)) ? 'engine' : undefined
389}
390
391/** When a plate was read, as a person reads a time: "as of Wed 7 Oct 09:12". */
392function asOf(at: string): string | undefined {
393 const time = Date.parse(at)
394 if (Number.isNaN(time)) return undefined
395 const zone = machineZone()
396 const parts = new Intl.DateTimeFormat('en-GB', {
397 weekday: 'short',
398 day: 'numeric',
399 month: 'short',
400 hour: '2-digit',
401 minute: '2-digit',
402 hourCycle: 'h23',
403 ...(zone === undefined ? {} : { timeZone: zone }),
404 }).formatToParts(time)
405 const part = (type: string) => parts.find(p => p.type === type)?.value ?? ''
406 return `as of ${part('weekday')} ${part('day')} ${part('month')} ${part('hour')}:${part('minute')}`
407}
408
409/** A calendar day (YYYY-MM-DD) as its parts; the reply's days are already the person's own. */
410function dayOf(day: string): { weekday: string; date: number; month: string; year: number } | undefined {
411 if (!isDay(day)) return undefined
412 const [year, month, date] = day.split('-').map(Number) as [number, number, number]
413 const at = new Date(Date.UTC(year, month - 1, date))
414 if (at.getUTCMonth() !== month - 1) return undefined
415 return { weekday: WEEKDAYS[at.getUTCDay()]!, date, month: MONTHS[month - 1]!, year }
416}
417
418/** "Thu 8 Oct". */
419function dayName(day: string): string | undefined {
420 const parts = dayOf(day)
421 return parts === undefined ? undefined : `${parts.weekday} ${parts.date} ${parts.month}`
422}
423
424/** "last moved 2 Sep", with the year when it is not today's. */
425function movedLabel(day: string, today: string): string | undefined {
426 const parts = dayOf(day)
427 if (parts === undefined) return undefined
428 const isThisYear = dayOf(today)?.year === parts.year
429 return `last moved ${parts.date} ${parts.month}${isThisYear ? '' : ` ${parts.year}`}`
430}
431
432/** "15 need you · 3 waiting on others · 1 coming up · 197 later". */
433const countsLine = (counts: PlateCounts) =>
434 [
435 `${counts.now} ${counts.now === 1 ? 'needs' : 'need'} you`,
436 `${counts.waiting} waiting on others`,
437 `${counts.coming} coming up`,
438 `${counts.later} later`,
439 ].join(DOT)
440
441/** A member's name from the reply's people, on one line; none when the reply has no name. */
442function nameOf(people: Record<string, string>, who: number): string | undefined {
443 const name = textOf(people[String(who)])
444 return name === undefined ? undefined : oneLine(name)
445}
446
447/** A card's tag: its words, the row field they are read from, and its colour. */
448type Tag = { text: string; source: string; tone: string }
449
450/**
451 * A card's tag, from the row's own fields and never from the plain words: how late, how long it
452 * has waited on the person, started, on whom it waits and how long, or when it is due; "(A GUESS)"
453 * where the fact it states was read from words and is unsure.
454 */
455function tagOf(row: PlateRow, people: Record<string, string>): Tag | undefined {
456 const guess = (flag: unknown) => (flag === true ? ' (A GUESS)' : '')
457 const days = (n: number) => `${n} ${n === 1 ? 'DAY' : 'DAYS'}`
458 const since = isNumber(row.since_days) && row.since_days >= 0 ? `${DOT}${row.since_days === 0 ? 'TODAY' : days(row.since_days)}` : ''
459 const due = isDay(row.due) ? dayName(row.due) : undefined
460 switch (row.why) {
461 case 'late': {
462 const late = isNumber(row.days_late) && row.days_late > 0 ? `${days(row.days_late)} LATE` : 'LATE'
463 return { text: `${late}${guess(row.due_suggested)}`, source: 'row.days_late', tone: 'red' }
464 }
465 case 'today':
466 return { text: `DUE TODAY${guess(row.due_suggested)}`, source: 'row.why', tone: 'yellow' }
467 case 'tomorrow':
468 return { text: `DUE TOMORROW${guess(row.due_suggested)}`, source: 'row.why', tone: 'yellow' }
469 case 'waiting_on_you':
470 return { text: `WAITING ON YOU${since}${guess(row.waiting_suggested)}`, source: 'row.since_days', tone: 'yellow' }
471 case 'started':
472 return { text: 'STARTED', source: 'row.why', tone: 'green' }
473 case 'waiting':
474 case 'handed': {
475 const on = isNumber(row.on) ? nameOf(people, row.on) : typeof row.on === 'string' ? textOf(oneLine(row.on)) : undefined
476 const head = on === undefined ? 'WAITING' : `ON ${on.toUpperCase()}`
477 const unsure = row.why === 'handed' ? row.doer_suggested : row.waiting_suggested
478 return { text: `${head}${since}${guess(unsure)}`, source: 'row.on', tone: 'magenta' }
479 }
480 case 'due':
481 default:
482 return due === undefined
483 ? undefined
484 : { text: `DUE ${due.toUpperCase()}${guess(row.due_suggested)}`, source: 'row.due', tone: 'cyan' }
485 }
486}
487
488/**
489 * A card's dim line: what it is part of, who it is from, when it last moved, each where known, and
490 * "maybe yours (a guess)" where the board is unsure the item is the person's to do. On a handed row
491 * that guess is about the member it waits on, and its tag says so.
492 */
493function linePieces(row: PlateRow, plate: PlateReply): { key: string; text: string }[] {
494 const pieces: { key: string; text: string }[] = []
495 const part = partOf(row.part)
496 if (part !== undefined) {
497 pieces.push({ key: `datum:row.part:${row.id}`, text: part.unfiled === true ? 'Not filed yet' : oneLine(part.name ?? '') })
498 }
499 if (row.from === undefined) {
500 // The reply names a writer only when it is not the person whose plate this is.
501 pieces.push({ key: `datum:row.from:${row.id}`, text: 'written by you' })
502 } else if (isNumber(row.from)) {
503 const writer = nameOf(plate.people, row.from)
504 if (writer !== undefined) pieces.push({ key: `datum:people:from:${row.id}`, text: `from ${writer}` })
505 }
506 const moved = isDay(row.moved) ? movedLabel(row.moved, plate.today) : undefined
507 if (moved !== undefined) pieces.push({ key: `datum:row.moved:${row.id}`, text: moved })
508 if (row.doer_suggested === true && row.why !== 'handed') {
509 pieces.push({ key: `datum:row.doer_suggested:${row.id}`, text: 'maybe yours (a guess)' })
510 }
511 return pieces
512}
513
514/** The last plate this session read, as the pane holds it now. */
515function lastOf(current: PlateView): PlateReply | null {
516 if (current.kind === 'shown') return current.plate
517 if ('last' in current) return current.last
518 return null
519}
520
521/** Sets one later group's view. */
522const setGroup = ($: EngineInterface, key: string, next: PlateGroupView) =>
523 update($, groups, (current): Record<string, PlateGroupView> => ({ ...current, [key]: next }))
524
525/**
526 * Shows a plate. A row's done stays where it stood while its item is still on the plate, and is
527 * forgotten once a plate arrives without it; its talk is offered again by every plate that arrives.
528 * A later group whose count changed (or that is no longer listed) loses its kept rows, and closes.
529 */
530async function show($: EngineInterface, plate: PlateReply, server: string, named: boolean): Promise<void> {
531 const onPlate = new Set([...plate.now, ...plate.waiting, ...plate.coming].map(row => String(row.id)))
532 await update($, asked, (current): Record<string, PlateAsk> =>
533 Object.fromEntries(Object.entries(current).filter(([id]) => onPlate.has(id))),
534 )
535 await update($, talks, (current): Record<string, PlateAsk> =>
536 Object.fromEntries(Object.entries(current).filter(([id, state]) => onPlate.has(id) && state === 'sending')),
537 )
538 const counts = new Map((plate.later_groups ?? []).map(group => [keyOf(group), group.count]))
539 const stale = Object.entries(await read($, groups))
540 .filter(([key, state]) => state.kind === 'rows' && counts.get(key) !== state.count)
541 .map(([key]) => key)
542 if (stale.length > 0) {
543 await update($, groups, (current): Record<string, PlateGroupView> =>
544 Object.fromEntries(
545 Object.entries(current).filter(([key, state]) => !(stale.includes(key) && state.kind === 'rows' && counts.get(key) !== state.count)),
546 ),
547 )
548 await update($, openGroup, current => (current !== null && stale.includes(current) ? null : current))
549 }
550 await update($, source, () => server)
551 await update($, plateHeld, () => false)
552 await update($, view, (): PlateView => ({ kind: 'shown', plate, server, named }))
553}
554
555/** What one plate read found, before the pane shows it, and the boards it found on the way. */
556type Found = { servers?: string[] } & (
557 | { kind: 'plate'; plate: PlateReply; server: string; named: boolean }
558 | { kind: 'refused' }
559 | { kind: 'blocked' }
560 | { kind: 'absent' }
561 | { kind: 'off' }
562 | { kind: 'failed' }
563)
564
565/** Finds the board among the connected tools and asks it for the plate; changes nothing. */
566async function findThePlate($: EngineInterface): Promise<Found> {
567 let servers: string[]
568 try {
569 servers = boardsAmong(await $.tool.list())
570 } catch {
571 return { kind: 'failed' }
572 }
573 if (servers.length === 0) return { kind: 'absent', servers }
574 const zone = machineZone()
575 const args = { query: WORDS, view: 'plate', ...(zone === undefined ? {} : { time_zone: zone }), conversation: CONVERSATION }
576 let isRefused = false
577 let isBlocked = false
578 let isFailed = false
579 let isOff = false
580 for (const server of servers) {
581 let words: string
582 try {
583 const result = await $.mcp.call(server, 'recall', args)
584 const blocks = blocksOf(result)
585 const answer = result.isError ? undefined : answerAmong(blocks)
586 if (answer?.kind === 'plate') return { kind: 'plate', plate: answer.plate, server, named: servers.length > 1, servers }
587 // Answered, and not with a plate: a server that is not this person's board. An error the
588 // board itself answered is the board's word, never the engine refusing the pane.
589 if (!result.isError) continue
590 words = blocks.join('\n')
591 } catch (error) {
592 words = error instanceof Error ? error.message : String(error)
593 const refusal = refusalOf(words)
594 if (refusal !== undefined) {
595 if (refusal === 'classifier') isRefused = true
596 else isBlocked = true
597 continue
598 }
599 }
600 if (namesThePlate(words)) isOff = true
601 else isFailed = true
602 }
603 if (isRefused) return { kind: 'refused', servers }
604 if (isBlocked) return { kind: 'blocked', servers }
605 return { kind: isFailed ? 'failed' : isOff ? 'off' : 'absent', servers }
606}
607
608/**
609 * Lifts every wait on the AI once the one question slot is free: the plate's line goes, and a
610 * group that was waiting closes, so its Open asks again on the person's next press.
611 */
612async function releaseHolds($: EngineInterface): Promise<void> {
613 await update($, plateHeld, () => false)
614 const held = Object.entries(await read($, groups))
615 .filter(([, state]) => state.kind === 'held')
616 .map(([key]) => key)
617 if (held.length === 0) return
618 await update($, groups, (current): Record<string, PlateGroupView> =>
619 Object.fromEntries(Object.entries(current).filter(([key]) => !held.includes(key))),
620 )
621 await update($, openGroup, current => (current !== null && held.includes(current) ? null : current))
622}
623
624/**
625 * Ends the question that is out, by its own number: answered (what it asked for has been drawn),
626 * unanswered (its turn ended with nothing), or unsent (the session did not take it). The slot is
627 * then free. A question that is no longer the one out is left alone, so a press that saw an older
628 * question can never end the one another press has just put.
629 */
630async function endQuestion($: EngineInterface, how: 'answered' | 'unanswered' | 'unsent', id: number | undefined): Promise<void> {
631 let ended = null as PlateQuestion | null
632 await update($, question, (current): PlateQuestion => {
633 const isIt = current.state !== 'none' && current.id === id
634 ended = isIt ? current : null
635 return isIt ? { state: 'none' } : current
636 })
637 if (ended === null || ended.state === 'none') return
638 const out = ended
639 if (how !== 'answered') {
640 const kind = how
641 const group = out.group
642 if (group === undefined) {
643 await update($, view, (current): PlateView =>
644 current.kind === 'asking' || current.kind === 'asked' ? { kind, last: current.last } : current,
645 )
646 } else {
647 await update($, groups, (current): Record<string, PlateGroupView> => {
648 const now = current[group]
649 return now?.kind === 'asking' || now?.kind === 'asked' ? { ...current, [group]: { kind } } : current
650 })
651 }
652 }
653 await releaseHolds($)
654}
655
656/**
657 * The number of the question out when it is lost: taken by the session, held by no turn, the
658 * session idle, and the turn it waited behind ended more than QUIET_MS ago. Queued behind a turn
659 * the person then cancelled, it never runs, and would otherwise keep every later press from asking.
660 */
661async function lostQuestion($: EngineInterface): Promise<number | undefined> {
662 const out = await read($, question)
663 if (out.state !== 'waiting' || out.turnId !== null || out.behindEndedAt === undefined) return undefined
664 if ((await read($, runningTurn)) !== null) return undefined
665 return (await $.clock.now()) - out.behindEndedAt > QUIET_MS ? out.id : undefined
666}
667
668/**
669 * Ends a question a turn held only tentatively, once QUIET_MS has passed after that turn with no
670 * turn starting: had that turn been one queued ahead of it, the question's own would have started.
671 */
672async function endIfQuiet($: EngineInterface, id: number | undefined, startedAtEnd: number): Promise<void> {
673 const out = await read($, question)
674 if (out.state === 'none' || out.id !== id || out.turnId !== null || turnsStarted !== startedAtEnd) return
675 await endQuestion($, 'unanswered', id)
676}
677
678/**
679 * Puts one question to the person's AI, for a read the person started that the engine refused:
680 * the plate's, or a later group's (`group`). One question at a time: while another is out, nothing
681 * is sent and the pane says it waits on the AI. The question goes from a timer, outside the
682 * dispatch that asked, so it never waits on a turn that dispatch holds.
683 */
684async function askTheAI($: EngineInterface, text: string, group?: string): Promise<void> {
685 // A question that will never run frees the slot for this press: that question, by its number.
686 const lost = await lostQuestion($)
687 if (lost !== undefined) await endQuestion($, 'unanswered', lost)
688 questionsPut += 1
689 const id = questionsPut
690 let isNew = false
691 await update($, question, (current): PlateQuestion => {
692 isNew = current.state === 'none'
693 return isNew ? { state: 'sending', id, text, turnId: null, ...(group === undefined ? {} : { group }) } : current
694 })
695 const out = await read($, question)
696 if (out.state === 'none' || out.group !== group) {
697 if (group === undefined) await update($, plateHeld, () => true)
698 else await setGroup($, group, { kind: 'held' })
699 return
700 }
701 const kind = isNew || out.state === 'sending' ? 'asking' : 'asked'
702 if (group === undefined) await update($, view, (current): PlateView => ({ kind, last: lastOf(current) }))
703 else await setGroup($, group, { kind })
704 if (!isNew) return
705 $.clock.after(0, () => {
706 void putTheQuestion($, id, text, group).catch(() => undefined)
707 })
708}
709
710/**
711 * Submits the question as the person's own words, and says so plainly when it was not taken. It
712 * drops out, sending nothing, when its question is no longer the one out. A question put while
713 * another main-loop turn runs is queued behind it, and the session takes it at once: so the turn
714 * running now is noted on the question, and its end does not end it.
715 */
716async function putTheQuestion($: EngineInterface, id: number, text: string, group: string | undefined): Promise<void> {
717 const behind = await read($, runningTurn)
718 let isOut = false
719 await update($, question, (current): PlateQuestion => {
720 if (current.state !== 'sending' || current.id !== id) {
721 isOut = false
722 return current
723 }
724 isOut = true
725 return behind === null ? current : { ...current, behind }
726 })
727 if (!isOut) return
728 let isTaken = false
729 try {
730 isTaken = (await $.prompt.submit({ text, asUser: true })).drop === undefined
731 } catch {
732 isTaken = false
733 }
734 if (!isTaken) {
735 await endQuestion($, 'unsent', id)
736 return
737 }
738 await update($, question, (current): PlateQuestion =>
739 current.state === 'sending' && current.id === id ? { ...current, state: 'waiting' } : current,
740 )
741 if (group === undefined) {
742 await update($, view, (current): PlateView => (current.kind === 'asking' ? { kind: 'asked', last: current.last } : current))
743 } else {
744 await update($, groups, (current): Record<string, PlateGroupView> =>
745 current[group]?.kind === 'asking' ? { ...current, [group]: { kind: 'asked' } } : current,
746 )
747 }
748}
749
750/** A promise that answers 'late' once READ_LIMIT_MS has passed, and the timer to cancel. */
751function limit($: EngineInterface): { late: Promise<'late'>; cancel: () => void } {
752 let giveUp: () => void = () => {}
753 const late = new Promise<'late'>(resolve => {
754 giveUp = () => resolve('late')
755 })
756 const timer = $.clock.after(READ_LIMIT_MS, () => giveUp())
757 return { late, cancel: () => timer.cancel() }
758}
759
760/**
761 * Reads the plate and shows what it found, within READ_LIMIT_MS: a read that has not answered by
762 * then ends in the failed line, with the last plate this session read. A newer read wins over an
763 * older one that answers late.
764 *
765 * A read the person started tries the board every time. One a track started does not run while a
766 * person's read is on its way, nor at all once the engine has refused the pane in this session, so
767 * refusals never pile up. When the engine refuses, only a read the person started acts on it: the
768 * classifier's refusal asks the AI, any other says the pane may not read here. Any other read
769 * keeps what the pane shows.
770 */
771async function readPlate($: EngineInterface, by: 'person' | 'ai'): Promise<void> {
772 if (by === 'ai' && (personReads > 0 || (await read($, refusedHere)))) return
773 newestRead += 1
774 const mine = newestRead
775 if (by === 'person') personReads += 1
776 const timer = limit($)
777 let found: Found | 'late'
778 try {
779 found = await Promise.race([findThePlate($), timer.late])
780 } finally {
781 timer.cancel()
782 if (by === 'person') personReads -= 1
783 }
784 if (found !== 'late' && (found.kind === 'refused' || found.kind === 'blocked')) {
785 await update($, refusedHere, () => true)
786 } else if (found !== 'late' && found.kind === 'plate' && by === 'person') {
787 await update($, refusedHere, () => false)
788 }
789 if (mine !== newestRead) return
790 if (found !== 'late' && found.servers !== undefined) {
791 const servers = found.servers
792 await update($, boards, () => servers)
793 }
794 if (found === 'late' || found.kind === 'failed') {
795 await update($, view, (current): PlateView => ({ kind: 'failed', last: lastOf(current) }))
796 } else if (found.kind === 'plate') {
797 await show($, found.plate, found.server, found.named)
798 } else if (found.kind === 'refused') {
799 if (by === 'person') await askTheAI($, theQuestion())
800 } else if (found.kind === 'blocked') {
801 if (by === 'person') await update($, view, (current): PlateView => ({ kind: 'blocked', last: lastOf(current) }))
802 } else {
803 const kind = found.kind
804 await update($, view, (): PlateView => ({ kind }))
805 }
806}
807
808/** What one later group's read found. */
809type GroupFound = { kind: 'rows'; rows: PlateLaterRow[]; count: number } | { kind: 'refused' } | { kind: 'blocked' } | { kind: 'failed' }
810
811/** Asks the board the plate came from for one later group; changes nothing. */
812async function findTheGroup($: EngineInterface, server: string, key: string): Promise<GroupFound> {
813 const zone = machineZone()
814 const args = {
815 query: GROUP_WORDS,
816 view: 'plate',
817 part: partOfKey(key),
818 ...(zone === undefined ? {} : { time_zone: zone }),
819 conversation: CONVERSATION,
820 }
821 try {
822 const result = await $.mcp.call(server, 'recall', args)
823 const answer = result.isError ? undefined : answerAmong(blocksOf(result))
824 return answer?.kind === 'group' ? { kind: 'rows', rows: answer.rows, count: answer.count } : { kind: 'failed' }
825 } catch (error) {
826 const refusal = refusalOf(error instanceof Error ? error.message : String(error))
827 return refusal === 'classifier' ? { kind: 'refused' } : refusal === 'engine' ? { kind: 'blocked' } : { kind: 'failed' }
828 }
829}
830
831/**
832 * Reads one later group the person opened, the same way a plate read goes: directly, within
833 * READ_LIMIT_MS; refused by the classifier, one question to the AI on this press; refused
834 * otherwise, the line saying the pane may not read here. Its rows, once read, are kept. A group
835 * closed before its read answers drops that read: it asks no one.
836 */
837async function readGroup($: EngineInterface, group: PlateLaterGroup): Promise<void> {
838 const key = keyOf(group)
839 groupReadsMade += 1
840 const mine = groupReadsMade
841 newestGroupRead.set(key, mine)
842 await setGroup($, key, { kind: 'loading' })
843 const server = await read($, source)
844 let found: GroupFound | 'late' = { kind: 'failed' }
845 if (server !== null) {
846 const timer = limit($)
847 try {
848 found = await Promise.race([findTheGroup($, server, key), timer.late])
849 } finally {
850 timer.cancel()
851 }
852 }
853 if (found !== 'late' && (found.kind === 'refused' || found.kind === 'blocked')) {
854 await update($, refusedHere, () => true)
855 } else if (found !== 'late' && found.kind === 'rows') {
856 await update($, refusedHere, () => false)
857 }
858 if (newestGroupRead.get(key) !== mine) return
859 // Closed while its read was on its way: the read is dropped, and asks no one.
860 if ((await read($, openGroup)) !== key) {
861 await update($, groups, (current): Record<string, PlateGroupView> =>
862 current[key]?.kind === 'loading' ? Object.fromEntries(Object.entries(current).filter(([one]) => one !== key)) : current,
863 )
864 return
865 }
866 if (found === 'late' || found.kind === 'failed') await setGroup($, key, { kind: 'failed' })
867 else if (found.kind === 'rows') await setGroup($, key, found)
868 else if (found.kind === 'blocked') await setGroup($, key, { kind: 'blocked' })
869 else await askTheAI($, groupQuestion(group), key)
870}
871
872/**
873 * Refresh: forgets every later group's kept rows, closing the open one if it held them, so the
874 * next Open reads that group again; then reads the plate the way any read the person starts does.
875 */
876async function refreshPlate($: EngineInterface): Promise<void> {
877 const kept = Object.entries(await read($, groups))
878 .filter(([, state]) => state.kind === 'rows')
879 .map(([key]) => key)
880 if (kept.length > 0) {
881 await update($, groups, (current): Record<string, PlateGroupView> =>
882 Object.fromEntries(Object.entries(current).filter(([key, state]) => !(kept.includes(key) && state.kind === 'rows'))),
883 )
884 await update($, openGroup, current => (current !== null && kept.includes(current) ? null : current))
885 }
886 await readPlate($, 'person')
887}
888
889/** Opens a later group, closing any other, and reads it unless its rows are kept or on their way. */
890async function pressGroup($: EngineInterface, group: PlateLaterGroup): Promise<void> {
891 const key = keyOf(group)
892 if ((await read($, openGroup)) === key) {
893 await update($, openGroup, () => null)
894 return
895 }
896 await update($, openGroup, () => key)
897 const now = (await read($, groups))[key]
898 if (now?.kind === 'rows' || now?.kind === 'loading') return
899 await readGroup($, group)
900}
901
902/**
903 * Sends the person's AI one sentence, as the person's own words; it counts as sent only once the
904 * session took it.
905 */
906async function isTakenBy($: EngineInterface, text: string): Promise<boolean> {
907 try {
908 return (await $.prompt.submit({ text, asUser: true })).drop === undefined
909 } catch {
910 return false
911 }
912}
913
914/**
915 * Done: asks the person's AI to mark one item done, by its number alone ("Mark item <n> done."),
916 * once while the item is on the plate. It carries nothing anyone else wrote.
917 */
918async function askDone($: EngineInterface, row: PlateRow): Promise<void> {
919 const key = String(row.id)
920 // Decided where the value is written, so a double press posts once.
921 let isMine = false
922 await update($, asked, (current): Record<string, PlateAsk> => {
923 isMine = current[key] !== 'sending' && current[key] !== 'sent'
924 return isMine ? { ...current, [key]: 'sending' } : current
925 })
926 if (!isMine) return
927 const outcome: PlateAsk = (await isTakenBy($, `Mark item ${row.id} done.`)) ? 'sent' : 'unsent'
928 await update($, asked, (current): Record<string, PlateAsk> => ({ ...current, [key]: outcome }))
929}
930
931/**
932 * Talk about it: asks the person's AI about one item, by its number alone ("Tell me about item
933 * <n>."), once per item until a plate arrives again. It carries nothing anyone else wrote.
934 */
935async function askTalk($: EngineInterface, row: PlateRow): Promise<void> {
936 const key = String(row.id)
937 // Decided where the value is written, so a double press posts once.
938 let isMine = false
939 await update($, talks, (current): Record<string, PlateAsk> => {
940 isMine = current[key] !== 'sending' && current[key] !== 'sent'
941 return isMine ? { ...current, [key]: 'sending' } : current
942 })
943 if (!isMine) return
944 const outcome: PlateAsk = (await isTakenBy($, `Tell me about item ${row.id}.`)) ? 'sent' : 'unsent'
945 await update($, talks, (current): Record<string, PlateAsk> => ({ ...current, [key]: outcome }))
946}
947
948/**
949 * Follows the AI's own call to a board, once that call has answered, while the pane is open: a
950 * plate it read is drawn, and answers the plate's question if that is out; a later group it read
951 * is kept for that group, and answers that group's question; after anything else the pane reads
952 * the board again itself, and never asks the AI.
953 */
954async function followTheAI(
955 $: EngineInterface,
956 answer: Answer | undefined,
957 server: string,
958 askedFor: string | undefined,
959): Promise<void> {
960 const current = await read($, view)
961 if (current.kind === 'unasked') return
962 if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
963 if (answer === undefined) {
964 await readPlate($, 'ai')
965 return
966 }
967 const out = await read($, question)
968 if (answer.kind === 'plate') {
969 const known = await read($, boards)
970 if (out.state !== 'none' && out.group === undefined) await endQuestion($, 'answered', out.id)
971 await show($, answer.plate, server, known.length > 1)
972 return
973 }
974 // A later group's read never replaces the plate: it is that group's rows, kept under the part
975 // the AI named, else the group the reply names.
976 const key = askedFor ?? answer.key
977 if (key === undefined) return
978 groupReadsMade += 1
979 newestGroupRead.set(key, groupReadsMade)
980 await setGroup($, key, { kind: 'rows', rows: answer.rows, count: answer.count })
981 if (out.state !== 'none' && out.group === key) await endQuestion($, 'answered', out.id)
982}
983
984export const register: Register = (on, options) => {
985 if (options.plate === 'off') return
986
987 on('session.start', async ($, e, next) => {
988 await $.command.register({
989 name: 'plate',
990 description: 'Open your plate in a pane: what needs you now, what waits on someone else, what is coming up',
991 })
992 return next(e)
993 })
994
995 on('command.run', { command: 'plate' }, async $ => {
996 await update($, view, (current): PlateView => (current.kind === 'unasked' ? { kind: 'loading' } : current))
997 const opened = await $.ui.open({ id: PANE, title: TITLE })
998 await readPlate($, 'person')
999 return { text: opened.isPlaced ? 'Your plate is open.' : 'Your plate opens as soon as there is room for it.' }
1000 })
1001
1002 // The AI's own calls to a board, a subagent's included. Its call answers first, untouched; what
1003 // the pane does about it runs afterwards, from a timer, and never asks the AI anything.
1004 on('tool.call', async ($, e, next) => {
1005 const ran = await next(e)
1006 try {
1007 const known = await read($, boards)
1008 const name = String(e.tool)
1009 const server = known.find(one => name === `mcp__${one}__track` || name === `mcp__${one}__recall`)
1010 if (server === undefined) return ran
1011 if (name === `mcp__${server}__track`) {
1012 $.clock.after(0, () => {
1013 void followTheAI($, undefined, server, undefined).catch(() => undefined)
1014 })
1015 } else {
1016 const answer = answerInCall(ran)
1017 if (answer !== undefined) {
1018 const askedFor = answer.kind === 'group' ? keyOfAsked((e as { part?: unknown }).part) : undefined
1019 $.clock.after(0, () => {
1020 void followTheAI($, answer, server, askedFor).catch(() => undefined)
1021 })
1022 }
1023 }
1024 } catch {
1025 // The AI's call stands whatever happens here; the pane is only ever behind.
1026 }
1027 return ran
1028 }).catch(($, e, next) => next(e))
1029
1030 // The main loop's turns: which one runs now (noted from the start, and nothing else before the
1031 // person asks), and the question's own. The turn opening with the question's words is its own,
1032 // even after another turn held it. For a question queued behind a running turn, a later turn
1033 // opening with other words holds it only tentatively: it may be the question reworded, or a turn
1034 // queued ahead of it.
1035 on('turn.start', async ($, e, next) => {
1036 try {
1037 turnsStarted += 1
1038 await update($, runningTurn, () => e.turnId)
1039 const out = await read($, question)
1040 if (out.state !== 'none' && e.text === out.text) {
1041 await update($, question, (current): PlateQuestion =>
1042 current.state !== 'none' && current.id === out.id ? { ...current, turnId: e.turnId, exact: true } : current,
1043 )
1044 } else if (out.state !== 'none' && out.turnId === null && out.behind !== undefined && e.turnId !== out.behind) {
1045 await update($, question, (current): PlateQuestion =>
1046 current.state !== 'none' && current.id === out.id && current.turnId === null
1047 ? { ...current, turnId: e.turnId, exact: false }
1048 : current,
1049 )
1050 }
1051 } catch {
1052 // The turn goes on whatever happens here.
1053 }
1054 return next(e)
1055 }).catch(($, e, next) => next(e))
1056
1057 // A question ends with its own turn or, put while no turn ran, with the first main-loop turn to end
1058 // after the session took it, whatever words that turn opened with: it never stays out for the
1059 // session. The turn it was queued behind is not its turn: its end leaves the question out, and is
1060 // noted. A turn that held it only tentatively ends it only once no turn has started for QUIET_MS.
1061 on('turn.complete', async ($, e, next) => {
1062 const ended = await next(e)
1063 try {
1064 if (e.agentId === undefined) {
1065 await update($, runningTurn, current => (current === e.turnId ? null : current))
1066 const out = await read($, question)
1067 if (out.state === 'none') {
1068 // Nothing out.
1069 } else if (out.turnId === e.turnId && out.exact === false) {
1070 const at = await $.clock.now()
1071 const startedAtEnd = turnsStarted
1072 await update($, question, (current): PlateQuestion =>
1073 current.state !== 'none' && current.id === out.id && current.turnId === e.turnId
1074 ? { ...current, turnId: null, exact: undefined, behindEndedAt: at }
1075 : current,
1076 )
1077 $.clock.after(QUIET_MS, () => {
1078 void endIfQuiet($, out.id, startedAtEnd).catch(() => undefined)
1079 })
1080 } else if (out.turnId === e.turnId) {
1081 await endQuestion($, 'unanswered', out.id)
1082 } else if (out.behind === e.turnId) {
1083 const at = await $.clock.now()
1084 await update($, question, (current): PlateQuestion =>
1085 current.state !== 'none' && current.id === out.id ? { ...current, behindEndedAt: at } : current,
1086 )
1087 } else if (out.state === 'waiting' && out.turnId === null && out.behind === undefined) {
1088 await endQuestion($, 'unanswered', out.id)
1089 }
1090 }
1091 } catch {
1092 // The turn has ended whatever happens here.
1093 }
1094 return ended
1095 }).catch(($, e, next) => next(e))
1096
1097 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1098 const current = await read($, view)
1099 const { Box, Text, Button } = $.ui.resolve(e)
1100 if (current.kind === 'unasked') return <Box />
1101 const asks = await read($, asked)
1102 const talkAsks = await read($, talks)
1103 const chosen = await read($, tab)
1104 const openId = await read($, openCard)
1105 const openKey = await read($, openGroup)
1106 const groupViews = await read($, groups)
1107 const isHeld = await read($, plateHeld)
1108 // The AI answers after its current reply only while some other turn of the main loop runs.
1109 const running = await read($, runningTurn)
1110 const out = await read($, question)
1111 const isBusy = running !== null && (out.state === 'none' || out.turnId !== running)
1112
1113 const refresh = (
1114 <Box key="actions" marginTop={1}>
1115 <Button key="refresh" label="Refresh" onPress={() => refreshPlate($)} />
1116 </Box>
1117 )
1118
1119 // A read the person started was refused while a group's question was out: nothing was sent.
1120 const held = isHeld && (
1121 <Box gap={1}>
1122 <Box key="status:held">
1123 <Text dimColor>Waiting on your AI.</Text>
1124 </Box>
1125 <Text dimColor>Try again once it answers.</Text>
1126 </Box>
1127 )
1128
1129 const busyLine = isBusy && <Text dimColor>It answers after its current reply.</Text>
1130
1131 // A row's Done: its button, or where its sentence stands.
1132 const doneControl = (row: PlateRow) => {
1133 const ask = asks[String(row.id)]
1134 const button = <Button key={`done:${row.id}`} label="Done" variant="primary" onPress={() => askDone($, row)} />
1135 if (ask === 'sending') {
1136 return (
1137 <Box key={`status:sending:${row.id}`}>
1138 <Text dimColor>Sending to your AI.</Text>
1139 </Box>
1140 )
1141 }
1142 if (ask === 'sent') {
1143 return (
1144 <Box key={`status:sent:${row.id}`}>
1145 <Text dimColor>Sent to your AI.</Text>
1146 </Box>
1147 )
1148 }
1149 if (ask === 'unsent') {
1150 return [
1151 <Box key={`status:unsent:${row.id}`}>
1152 <Text>Not sent. Try again.</Text>
1153 </Box>,
1154 button,
1155 ]
1156 }
1157 return button
1158 }
1159
1160 // A row's Talk about it: its button, and where its sentence stands.
1161 const talkControl = (row: PlateRow) => {
1162 const ask = talkAsks[String(row.id)]
1163 const button = (
1164 <Button key={`talk:${row.id}`} label="Talk about it" variant="secondary" onPress={() => askTalk($, row)} />
1165 )
1166 if (ask === 'sending') {
1167 return (
1168 <Box key={`status:talk-sending:${row.id}`}>
1169 <Text dimColor>Sending to your AI.</Text>
1170 </Box>
1171 )
1172 }
1173 if (ask === 'sent') {
1174 return (
1175 <Box key={`status:talk-sent:${row.id}`}>
1176 <Text dimColor>Sent to your AI.</Text>
1177 </Box>
1178 )
1179 }
1180 if (ask === 'unsent') {
1181 return [
1182 <Box key={`status:talk-unsent:${row.id}`}>
1183 <Text>Not sent. Try again.</Text>
1184 </Box>,
1185 button,
1186 ]
1187 }
1188 return button
1189 }
1190
1191 const card = (plate: PlateReply, row: PlateRow) => {
1192 const isOpen = openId === row.id
1193 const tag = tagOf(row, plate.people)
1194 const tone = tag?.tone ?? 'gray'
1195 const pieces = linePieces(row, plate)
1196 const about = isOpen ? textOf(row.about) : undefined
1197 const done = asks[String(row.id)]
1198 return (
1199 <Box
1200 key={`card:${row.id}`}types/index.d.ts 173 lines1// The plate pane's contract: the reply it reads and the state it draws from.
2
3/**
4 * What an item is part of: its readable parent, by number and name, or, for work not filed yet,
5 * no number and no name, only the mark.
6 */
7export type PlatePart =
8 | { id: number; name: string | null; unfiled?: undefined }
9 | { id: null; name: null; unfiled: true }
10
11/** One row of a plate, as the board's recall answers it for the plate view. */
12export type PlateRow = {
13 /** The item's number: what a person says, and what the AI marks done. */
14 id: number
15 /** The item's name, else its summary, as its writer wrote it. */
16 title: string
17 /** Why it sits where it does: late, today, tomorrow, waiting_on_you, started, due, waiting, handed. */
18 why: string
19 due?: string
20 due_suggested?: boolean
21 days_late?: number
22 /** On whom or what a waiting row waits: a member's number, or words. */
23 on?: string | number
24 since?: string
25 since_days?: number
26 waiting_suggested?: boolean
27 doer_suggested?: boolean
28 /** The writer, when it is not the person whose plate this is. */
29 from?: number
30 /** What it is part of; absent when it has no parent the person can read (and from today's server). */
31 part?: PlatePart
32 /** The calendar day it last moved (YYYY-MM-DD), in the reply's zone; absent from today's server. */
33 moved?: string
34 /** A line of what it is, beyond its title; absent when the title already says it. */
35 about?: string
36}
37
38/** One later group: a readable parent, the unfiled group, or the group of items part of nothing. */
39export type PlateLaterGroup = {
40 id: number | null
41 name: string | null
42 unfiled?: boolean
43 count: number
44}
45
46/** One row of a later group read: what it is and the day it last moved. */
47export type PlateLaterRow = { id: number; title: string; moved?: string }
48
49/** How many items each group holds in all, beyond the rows it lists. */
50export type PlateCounts = { now: number; waiting: number; coming: number; later: number }
51
52/**
53 * The whole plate reply: the counts, the rows each group lists, and the plain words. A later
54 * group read is the same reply with the active groups empty and `later` holding that group's rows.
55 */
56export type PlateReply = {
57 view: 'plate'
58 as_of: string
59 today: string
60 time_zone: string
61 counts: PlateCounts
62 now: PlateRow[]
63 waiting: PlateRow[]
64 coming: PlateRow[]
65 /** The later items by what each belongs to, the largest 30; absent from today's server. */
66 later_groups?: PlateLaterGroup[]
67 /** How many later groups there are beyond those listed. */
68 later_groups_more?: number
69 /** A later group read's rows; absent on a plate look. */
70 later?: PlateLaterRow[]
71 people: Record<string, string>
72 /** The plate in plain words, as a chat shows it. The pane draws nothing from it. */
73 plain?: string
74}
75
76/** Where one row's done, or its talk, stands: on its way to the AI, taken by the session, or not taken. */
77export type PlateAsk = 'sending' | 'sent' | 'unsent'
78
79/** The tabs, one per group. */
80export type PlateTab = 'now' | 'waiting' | 'coming' | 'later'
81
82/**
83 * The one question the pane may have put to the person's AI, when the engine refused the pane its
84 * own read: none, on its way, or taken by the session and waiting for its turn.
85 */
86export type PlateQuestion =
87 | { state: 'none' }
88 | {
89 state: 'sending' | 'waiting'
90 /** This question's own number in the session. */
91 id?: number
92 text: string
93 /** The turn holding it, once one has started. */
94 turnId: string | null
95 /** Whether that turn opened with the question's own words; false when it holds it tentatively. */
96 exact?: boolean
97 /** The later group it asks for; absent, it asks for the plate. */
98 group?: string
99 /** The main-loop turn running when it was put, which it is queued behind. */
100 behind?: string
101 /** When the last turn it waited behind ended, in milliseconds since the epoch. */
102 behindEndedAt?: number
103 }
104
105/**
106 * What one later group shows once opened. Its rows, once read, are kept for the session. `held`
107 * means its read was refused while another question was out, so nothing was sent.
108 */
109export type PlateGroupView =
110 | { kind: 'loading' }
111 | { kind: 'rows'; rows: PlateLaterRow[]; count: number }
112 | { kind: 'asking' }
113 | { kind: 'asked' }
114 | { kind: 'held' }
115 | { kind: 'unanswered' }
116 | { kind: 'unsent' }
117 | { kind: 'blocked' }
118 | { kind: 'failed' }
119
120/** What the pane shows. Unasked until the person asks, and nothing is drawn while it is. */
121export type PlateView =
122 | { kind: 'unasked' }
123 | { kind: 'loading' }
124 | { kind: 'asking'; last: PlateReply | null }
125 | { kind: 'asked'; last: PlateReply | null }
126 | { kind: 'unanswered'; last: PlateReply | null }
127 | { kind: 'unsent'; last: PlateReply | null }
128 | { kind: 'blocked'; last: PlateReply | null }
129 | { kind: 'absent' }
130 | { kind: 'off' }
131 | { kind: 'shown'; plate: PlateReply; server: string; named: boolean }
132 | { kind: 'failed'; last: PlateReply | null }
133
134declare module 'claude-code' {
135 interface PluginState {
136 'dazzer-plate': {
137 /**
138 * What the pane shows. The last plate this session read lives here and nowhere else: held by
139 * the host for this session, never written to disk, never shared with another session.
140 */
141 view: PlateView
142 /** Each row's done, by item number, kept until that item leaves the plate. */
143 asked: Record<string, PlateAsk>
144 /** Each row's talk about it, by item number, until a plate arrives again. */
145 talks: Record<string, PlateAsk>
146 /** The servers offering both recall and track, as the latest read found them. */
147 boards: string[]
148 /** The server the plate shown came from: the one a later group is read from. */
149 source: string | null
150 /** The one question to the person's AI, while there is one. */
151 question: PlateQuestion
152 /**
153 * Whether the engine has refused the pane its own read in this session. A read the person
154 * starts still tries the board every time; one a track starts does not, so refusals never
155 * pile up in a busy session.
156 */
157 refusedHere: boolean
158 /** The main loop's turn running now, if any. */
159 runningTurn: string | null
160 /** The tab chosen. */
161 tab: PlateTab
162 /** The one card open, by item number. */
163 openCard: number | null
164 /** The one later group open, by its key: its number, `unfiled` or `none`. */
165 openGroup: string | null
166 /** Each later group opened this session, by its key. */
167 groups: Record<string, PlateGroupView>
168 /** A plate read the person started was refused while a group's question was out. */
169 plateHeld: boolean
170 }
171 }
172}
173