Everythings for Claude Code: workspace/thing MCP tools in every project, including asking you a question and picking up your answer later, the /things pane…

Everythings is a collaborative workspace app for notes, lists, and items ("things"). This plugin gives Claude Code three powers:
/things: a pane in Claude Code that draws your workspaces and things beside the transcript and opens each thing as the agent writes it. Press a mark to add or remove your own, or leave a comment, without leaving the session. /findthing <query> searches every workspace and lists the hits there. It needs a Claude Code build that runs plugin hook modules (the terminal or the desktop Code tab)./everythings:panel — your live panel — publishes a private claude.ai artifact showing your workspaces and things, with content, marks, comments, and a follow mode that auto-opens things as an agent creates them. Ask an agent to plan a trip and watch the plan assemble itself.claude plugin marketplace add bbabkin/everythings-claude
claude plugin install everythings@everythings-claude
(or interactively: /plugin marketplace add bbabkin/everythings-claude, then pick everythings under /plugin install.)
https://www.everythings.app/api/mcp./everythings:panel.Your panel is a private artifact on your claude.ai account — every user publishes their own; nothing is shared unless you share it.
Start a new Claude Code session after installing or updating, then run /things. Under auto permission mode, Claude Code refuses the pane's own calls until you allow them in permissions.allow of ~/.claude/settings.json:
"mcp__plugin_everythings_everythings__get_thing_view",
"mcp__plugin_everythings_everythings__get_workspace_view",
"mcp__plugin_everythings_everythings__get_workspace",
"mcp__plugin_everythings_everythings__search_things",
"mcp__plugin_everythings_everythings__add_mark",
"mcp__plugin_everythings_everythings__remove_mark",
"mcp__plugin_everythings_everythings__add_comment"
The first four are read-only; the last three are the marks and comments you make from the pane. When the pane shows a note, its Copy the rules button gives the names your setup uses. The full guide is at everythings.app/docs/agents/claude-code.
/things pane is a hooks module (hooks/register.tsx) that runs inside Claude Code. It watches one thing: the Everythings tool calls of the session it runs in, so it can draw what they carried. Its own calls go to the same Everythings MCP server through Claude Code, on the sign-in Claude Code already holds. It writes only when you act: your mark when you press a mark, your comment when you submit one, and one prompt in your name when you press "Ask Claude to open it", or when /things or /findthing opens on a page or a search it cannot read itself (once per page or search, naming the page by its id, or the search by the words you typed). It keeps one flag in Claude Code's plugin storage (that you dismissed a permissions note), copies permission rule text to the clipboard when you press Copy, and makes no network requests of its own.https://www.everythings.app/api/mcp. Claude Code runs that flow and stores the resulting token itself. For the panel, the claude.ai connector you connect runs its own OAuth sign-in and keeps its own token. The skill and the panel page never see either one.www.everythings.app, through the MCP tools that you or Claude call. The panel page makes no network requests of its own: claude.ai relays its tool calls through your connector. It keeps one value in its browser storage, the id of the workspace you last opened, so it reopens there.The MCP tools work there — a Cowork agent can research and file everything into your workspaces. Publishing the panel needs Claude Code (CLI or web) or claude.ai, so run /everythings:panel once there, then keep the panel open in a browser beside Cowork: with follow mode armed it opens each thing as the Cowork agent writes it.
mcp__<server>__<tool> allow rules to add to your permission settings, with a Copy button./everythings:panel after an update republishes the new page to your same URL.MIT (see LICENSE). The Everythings name and logo remain trademarks of their owner; the bundled Montserrat font is used under the SIL Open Font License.
hooks/register.tsx 372 lines1// The everythings mod: Everythings in a pane beside the transcript.
2//
3// This file is the engine boundary. The engine follows `$` only into
4// functions declared in the file whose hook received it, so every hook lives
5// here, and `ports($)` below is the one place the mod spells out `$.mcp`,
6// `$.store`, `$.ui`, `$.prompt`, `$.clock` and `$.state`. The logic is in the
7// other files, written against Ports: nav.ts (screens and reads), cache.ts
8// (what the session's calls carried), server.ts (which MCP server),
9// blocked.ts (when Claude Code refuses the pane's calls), ask.ts (asking
10// Claude to open a page or run a search), writes.ts (the person's own mark
11// and comment, the pane's only writes), follow.ts (push follow),
12// inbox.ts (the band above the prompt, and the wake on an answer),
13// showThing.ts (the tool), pane.tsx (the drawing), rows.tsx (the
14// transcript's rows of Everythings writes), data.ts (payloads). A
15// new feature is a file of that kind plus its hook here; the state it reads
16// is declared in ../types/index.d.ts.
17
18import { atom, read, update } from 'claude-code';
19import type { EngineInterface, Register, Timer } from 'claude-code';
20
21import { askClaude } from './ask';
22import { EMPTY_CACHE } from './cache';
23import { IDLE_INBOX, IDLE_LOAD, IDLE_WAKE, IDLE_WRITES, INITIAL_VIEW, PANE_ID, PANE_TITLE } from './data';
24import { observeCall } from './follow';
25import { isWaiting, pollAnswers, refreshInbox, WAKE_POLL_MS, watchAsk } from './inbox';
26import { pressInbox, readSnapshot, refreshView, resumeFollow, runSearch, unfilledPage } from './nav';
27import { drawBand, drawPane, paneActions } from './pane';
28import { cellOf, type Ports } from './ports';
29import { drawRow, judgeRow, writeRow } from './rows';
30import { SHOW_THING, showThing } from './showThing';
31
32const viewAtom = atom({ plugin: 'everythings', key: 'view' } as const, INITIAL_VIEW);
33// Shaped: after a hot reload that changes the cache's shape, bump the tag
34// and the old value reads as absent instead of drawing in the old shape.
35const cacheAtom = atom({ plugin: 'everythings', key: 'cache' } as const, EMPTY_CACHE, { shape: 'v3' });
36const loadAtom = atom({ plugin: 'everythings', key: 'load' } as const, IDLE_LOAD);
37const followAtom = atom({ plugin: 'everythings', key: 'follow' } as const, true);
38const serverAtom = atom({ plugin: 'everythings', key: 'server' } as const, null);
39const blockedAtom = atom({ plugin: 'everythings', key: 'blocked' } as const, null);
40const noteDismissedAtom = atom({ plugin: 'everythings', key: 'noteDismissed' } as const, false);
41const askedAtom = atom({ plugin: 'everythings', key: 'asked' } as const, null);
42const freshAtom = atom({ plugin: 'everythings', key: 'fresh' } as const, {});
43const defaultsAskedAtom = atom({ plugin: 'everythings', key: 'defaultsAsked' } as const, []);
44const writesAtom = atom({ plugin: 'everythings', key: 'writes' } as const, IDLE_WRITES, { shape: 'v2' });
45const inboxAtom = atom({ plugin: 'everythings', key: 'inbox' } as const, IDLE_INBOX);
46const wakeAtom = atom({ plugin: 'everythings', key: 'wake' } as const, IDLE_WAKE);
47
48/** The engine, as the logic files may use it. A write that changes nothing is skipped (cellOf). */
49function ports($: EngineInterface): Ports {
50 return {
51 view: cellOf(
52 () => read($, viewAtom),
53 change => update($, viewAtom, change),
54 ),
55 // The cache is large and every record makes a new one, so only the same value counts as equal.
56 cache: cellOf(
57 () => read($, cacheAtom),
58 change => update($, cacheAtom, change),
59 Object.is,
60 ),
61 load: cellOf(
62 () => read($, loadAtom),
63 change => update($, loadAtom, change),
64 ),
65 follow: cellOf(
66 () => read($, followAtom),
67 change => update($, followAtom, change),
68 ),
69 server: cellOf(
70 () => read($, serverAtom),
71 change => update($, serverAtom, change),
72 ),
73 blocked: cellOf(
74 () => read($, blockedAtom),
75 change => update($, blockedAtom, change),
76 ),
77 noteDismissed: cellOf(
78 () => read($, noteDismissedAtom),
79 change => update($, noteDismissedAtom, change),
80 ),
81 asked: cellOf(
82 () => read($, askedAtom),
83 change => update($, askedAtom, change),
84 ),
85 fresh: cellOf(
86 () => read($, freshAtom),
87 change => update($, freshAtom, change),
88 ),
89 defaultsAsked: cellOf(
90 () => read($, defaultsAskedAtom),
91 change => update($, defaultsAskedAtom, change),
92 ),
93 writes: cellOf(
94 () => read($, writesAtom),
95 change => update($, writesAtom, change),
96 ),
97 inbox: cellOf(
98 () => read($, inboxAtom),
99 change => update($, inboxAtom, change),
100 ),
101 wake: cellOf(
102 () => read($, wakeAtom),
103 change => update($, wakeAtom, change),
104 ),
105 now: () => $.clock.now(),
106 mcpCall: (server, tool, args) => $.mcp.call(server, tool, args),
107 mcpConnect: async server => {
108 const answer = await $.mcp.connect(server);
109 return answer.isConnected
110 ? { isConnected: true, server: answer.server }
111 : { isConnected: false, message: answer.message };
112 },
113 storeGet: key => $.store.get(key),
114 storeSet: (key, value) => $.store.set(key, value),
115 openPane: async () => void (await $.ui.open({ id: PANE_ID, title: PANE_TITLE })),
116 isPaneOpen: async () => (await $.ui.panes()).some(pane => pane.id === PANE_ID),
117 askClaude: async text => (await $.prompt.submit({ text, asUser: true })).drop === undefined,
118 wakeSession: async text => void (await $.prompt.submit({ text })),
119 copy: async (text, surface) => (await $.ui.copy({ text, surface })).isCopied,
120 };
121}
122
123export const register: Register = on => {
124 on('session.start', async ($, e, next) => {
125 await $.command.register({
126 name: 'things',
127 description: 'Open the Everythings pane: your workspaces and things, following what the agent writes',
128 });
129 await $.command.register({
130 name: 'findthing',
131 description: 'Search your Everythings things in every workspace and list them in the pane',
132 argumentHint: '<query>',
133 });
134 await $.tool.register(SHOW_THING);
135 const started = await next(e);
136 // The band's counts, read once the session is up, on the server an earlier session learned; left running.
137 const p = ports($);
138 void refreshInbox(p).catch(() => undefined);
139 // After a hot reload the poll's timer is gone; a question still waited on starts it again.
140 if (await isWaiting(p)) startPoll($);
141 return started;
142 });
143
144 // `/things` opens the pane on the view it last showed, arms follow,
145 // and reads that view again. A screen that read leaves empty asks Claude
146 // for the page, so the pane never opens on a blank or an error alone:
147 // Claude's own calls reach the connector under whatever name it runs, and
148 // teach the pane its server. The engine refuses a prompt submitted from
149 // this hook (it would wait on the turn the hook holds), so the ask goes
150 // out from a one-shot `$.clock.after` once the command has returned, and
151 // only if the screen is still empty then.
152 on('command.run', { command: 'things' }, async $ => {
153 const p = ports($);
154 await p.openPane();
155 await resumeFollow(p);
156 await refreshView(p);
157 if ((await unfilledPage(p)) !== null) $.clock.after(ASK_MS, () => void askIfUnfilled($));
158 return {};
159 });
160
161 // `/findthing <query>` opens the pane on the search's hits, from one
162 // search_things call of the pane's own, and pauses follow. A search the
163 // pane could not run (refused, failed, or its reads blocked) is asked of
164 // Claude once, the way `/things` asks for an empty page: from a one-shot
165 // `$.clock.after` once the command has returned. The agent's own
166 // search_things answer then fills the list through the follow hook.
167 on('command.run', { command: 'findthing' }, async ($, e) => {
168 const p = ports($);
169 await p.openPane();
170 await runSearch(p, e.args);
171 if ((await unfilledPage(p)) !== null) $.clock.after(ASK_MS, () => void askIfUnfilled($));
172 return {};
173 });
174
175 // The model's `mcp__everythings__show_thing`.
176 on('tool.call', { tool: 'mcp__everythings__show_thing' }, async ($, e) => ({
177 result: await showThing(ports($), e as unknown as Record<string, unknown>),
178 }));
179
180 // Push follow and the session cache. The matcher admits the tools in
181 // UNIQUE_TOOLS and the followed, recorded or writing ones of GENERIC_TOOLS
182 // (data.ts), on any server, so no other call (Bash, Read, another server's
183 // tools) reaches the plugin. The call goes on as it came and its result
184 // comes back as it was; nothing that follow does may change or break it.
185 on(
186 'tool.call',
187 {
188 tool: /^mcp__.+__(?:what_changed|my_reception|search_things|get_thing|get_thing_view|get_workspace_view|list_things|list_child_things|recent_things|create_thing|update_thing|delete_thing|restore_thing|move_thing|reorder_things|copy_thing|claim_thing|release_thing|request_input|answer_request|resolve_request|list_requests|list_mentions|add_comment|add_mark|remove_mark|resolve_mention|list_workspaces|get_workspace|list_comments|list_marks|create_workspace|rename_workspace)$/,
189 },
190 async ($, e, next) => {
191 const ran = await next(e);
192 try {
193 await observeCall(ports($), e.tool, e as unknown as Record<string, unknown>, ran);
194 } catch {
195 // Follow is a convenience; the call's own result is what matters.
196 }
197 try {
198 // The session's own question: the wake poll waits on it.
199 if (await watchAsk(ports($), e.tool, ran)) startPoll($);
200 } catch {
201 // The question goes unwatched; the person's answer still reaches the agent's next run.
202 }
203 return ran;
204 },
205 );
206
207 // Transcript rows: a resolved Everythings write reads as one compact row
208 // ("updated 🍋 Lemon cake", the name a Link to the thing in the app), and
209 // the result block under it draws nothing (rows.tsx). The matchers admit
210 // the write tools of ROW_VERBS alone, on any server; rows.tsx then tells
211 // an Everythings server as follow does. An errored, running or
212 // interrupted call, or one rows.tsx cannot judge, keeps the engine's row.
213 on(
214 'ui.render',
215 {
216 component: 'ToolUse',
217 props: {
218 tool: /^mcp__.+__(?:create_thing|update_thing|move_thing|delete_thing|restore_thing|copy_thing|add_mark|remove_mark|add_comment|request_input)$/,
219 },
220 },
221 async ($, e, next) => {
222 try {
223 const row = await writeRow(ports($), e.props);
224 if (row) return drawRow($.ui.resolve(e), row);
225 } catch {
226 // A row the mod cannot draw is the engine's.
227 }
228 return next(e);
229 },
230 );
231 on(
232 'ui.render',
233 {
234 component: 'ToolResult',
235 props: {
236 tool: /^mcp__.+__(?:create_thing|update_thing|move_thing|delete_thing|restore_thing|copy_thing|add_mark|remove_mark|add_comment|request_input)$/,
237 },
238 },
239 async ($, e, next) => {
240 try {
241 if (await judgeRow(ports($), e.props.tool, e.props.isErrored, e.props.output)) {
242 const { Box } = $.ui.resolve(e);
243 return <Box />;
244 }
245 } catch {
246 // The engine's own result block.
247 }
248 return next(e);
249 },
250 );
251
252 // The band above the prompt: the open questions and @Agent jobs, a Button
253 // each, hidden at zero (inbox.ts). A press opens the pane on that list. A
254 // survey holds the band, and the mod yields it.
255 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
256 if (e.props.hasSurvey) return next(e);
257 try {
258 const p = ports($);
259 const band = drawBand($.ui.resolve(e), await p.inbox.read(), {
260 open: list => void pressInbox(p, list).catch(() => undefined),
261 });
262 if (band !== null) return band;
263 } catch {
264 // The band is the engine's.
265 }
266 return next(e);
267 });
268
269 on('ui.render', { component: 'Pane', requestId: 'everythings' }, async ($, e) => {
270 const p = ports($);
271 return drawPane($.ui.resolve(e), e.surface, await readSnapshot(p), paneActions(p));
272 });
273
274 // After a press, a submitted comment or a picked workspace in the pane,
275 // the pane asks for the keyboard back, once. The element acted on leaves
276 // the redrawn tree (a posted comment's field comes back under a new key)
277 // and the pane loses the keyboard with it (seen live 2026-10-03 in the
278 // desktop app), so the next click only took focus and pressed nothing.
279 // A one-shot `$.clock.after`, started by the
280 // person's act and replaced by the next one; it runs once. Nothing asks
281 // again later: a second ask that fell between a click's focus and its
282 // press was seen to swallow the press. The phone has no keyboard to give,
283 // and a closed pane is left closed.
284 on('ui.press', { plugin: 'everythings', component: 'Pane', requestId: 'everythings' }, async ($, e, next) => {
285 const pressed = await next(e);
286 if (e.surface !== 'mobile') refocusSoon($);
287 return pressed;
288 });
289 on('ui.input', { plugin: 'everythings', component: 'Pane', requestId: 'everythings' }, async ($, e, next) => {
290 const typed = await next(e);
291 if (e.kind === 'submit' && e.surface !== 'mobile') refocusSoon($);
292 return typed;
293 });
294 on('ui.select', { plugin: 'everythings', component: 'Pane', requestId: 'everythings' }, async ($, e, next) => {
295 const picked = await next(e);
296 if (e.surface !== 'mobile') refocusSoon($);
297 return picked;
298 });
299};
300
301/** How long after `/things` or `/findthing` returns an empty pane asks Claude for its page. */
302const ASK_MS = 100;
303
304/** Asks Claude for the page on screen when nothing draws there; once per page (ask.ts). */
305async function askIfUnfilled($: EngineInterface): Promise<void> {
306 try {
307 const p = ports($);
308 const target = await unfilledPage(p);
309 if (target !== null) await askClaude(p, target);
310 } catch {
311 // The pane keeps its notice and the ask Button.
312 }
313}
314
315/**
316 * The wake poll: the mod's one repeating timer. It runs while a question
317 * this session asked is waited on, every WAKE_POLL_MS, and stops itself when
318 * none is left (answered, past its two hours, or a refused poll). A period
319 * that comes while the last still runs does nothing.
320 */
321let poll: Timer | null = null;
322let isPolling = false;
323
324function startPoll($: EngineInterface): void {
325 if (poll !== null) return;
326 poll = $.clock.every(WAKE_POLL_MS, () => void pollOnce($));
327}
328
329async function pollOnce($: EngineInterface): Promise<void> {
330 if (isPolling) return;
331 isPolling = true;
332 let goesOn = true;
333 try {
334 goesOn = await pollAnswers(ports($));
335 } catch {
336 // The next period asks again.
337 } finally {
338 isPolling = false;
339 }
340 if (!goesOn) {
341 poll?.cancel();
342 poll = null;
343 // A question asked while this period ran starts the poll again.
344 if (await isWaiting(ports($)).catch(() => false)) startPoll($);
345 }
346}
347
348/** How long after a press the pane asks for the keyboard back. */
349const REFOCUS_MS = 200;
350/** The refocus an act started and that has not run yet; a later act replaces it. */
351let pendingRefocus: Timer | null = null;
352
353/** Asks for the keyboard back REFOCUS_MS from now, in place of an ask still pending. */
354function refocusSoon($: EngineInterface): void {
355 pendingRefocus?.cancel();
356 pendingRefocus = $.clock.after(REFOCUS_MS, () => {
357 pendingRefocus = null;
358 void refocusPane($);
359 });
360}
361
362/** Asks for the keyboard for the open pane, unless it holds it. A refusal (text in the composer, a dialog) is silent. */
363async function refocusPane($: EngineInterface): Promise<void> {
364 try {
365 const pane = (await $.ui.panes()).find(one => one.id === PANE_ID);
366 if (!pane || pane.isFocused) return;
367 await $.ui.open({ id: PANE_ID, title: PANE_TITLE, focus: true });
368 } catch {
369 // The pane goes on without the keyboard.
370 }
371}
372hooks/ask.ts 92 lines1// Asking Claude to open a page the pane knows only by name.
2//
3// A tile the agent has not read has only a name and an emoji in the cache,
4// and a workspace may have nothing listed yet. When the pane cannot fill the
5// page itself (Claude Code refuses its reads, or a read failed), a Button
6// submits one prompt as the person, naming the page by its id alone. Names
7// are written by other members and by agents, and the prompt reads as the
8// person's own words, so no name goes into it; an id that is not a plain
9// id sends nothing. The agent then reads the page, and the follow hook
10// records what that call carried, which fills it. `asked` names the page
11// asked about, so the Button stays gone until the page fills or the person
12// leaves the page.
13//
14// A search (`/findthing`) the pane could not run itself is asked for the
15// same way. Its query is the person's own typed text, so it goes into the
16// prompt, on one line; the agent's search_things answer then fills the list.
17
18import type { EverythingsCache, EverythingsView } from '../types';
19import { landingOf } from './cache';
20import { cleanQuery, searchKey } from './data';
21import type { Ports } from './ports';
22
23/** What the pane may ask Claude to open. A workspace with no id is the one the pane opens on. */
24export type AskTarget =
25 | { kind: 'thing'; id: string }
26 | { kind: 'workspace'; id: string | null }
27 | { kind: 'search'; query: string };
28
29/** The ids the server mints: letters, digits and dashes. */
30const PLAIN_ID = /^[A-Za-z0-9-]{1,64}$/;
31
32function keyOf(target: AskTarget): string {
33 if (target.kind === 'search') return `search:${searchKey(target.query)}`;
34 return target.kind === 'thing' ? `thing:${target.id}` : `workspace:${target.id ?? ''}`;
35}
36
37/** The page a view shows, as `asked` names it. */
38export function askKey(view: EverythingsView, cache: EverythingsCache): string {
39 if (view.kind === 'search') return keyOf({ kind: 'search', query: view.query });
40 if (view.kind === 'inbox') return `inbox:${view.list}`;
41 return view.kind === 'thing'
42 ? keyOf({ kind: 'thing', id: view.thingId })
43 : keyOf({ kind: 'workspace', id: landingOf(cache, view.workspaceId) });
44}
45
46/**
47 * The prompt, in the person's words, naming the page by its id (a search by
48 * the query they typed); null when the id is not a plain id or the query is empty.
49 */
50export function askText(target: AskTarget): string | null {
51 if (target.kind === 'search') {
52 const query = cleanQuery(target.query);
53 return query ? `Search my Everythings things for "${query}" with search_things.` : null;
54 }
55 if (target.kind === 'workspace' && target.id === null) return 'Show me my default Everythings workspace in the Everythings pane.';
56 if (target.id === null || !PLAIN_ID.test(target.id)) return null;
57 return `Show me Everythings ${target.kind} ${target.id} in the Everythings pane.`;
58}
59
60/**
61 * Submits one prompt as the person, once per pending page. The press marks
62 * the page asked before it submits, so a second press finds the mark and
63 * submits nothing. A prompt that did not enter clears the mark, so the
64 * Button comes back.
65 */
66export async function askClaude(p: Ports, target: AskTarget): Promise<void> {
67 const text = askText(target);
68 if (text === null) return;
69 const key = keyOf(target);
70 let isMine = false;
71 await p.asked.update(asked => {
72 isMine = asked !== key;
73 return key;
74 });
75 if (!isMine) return;
76 let hasEntered = false;
77 try {
78 hasEntered = await p.askClaude(text);
79 } catch {
80 // Cleared below, as a prompt that did not enter.
81 }
82 if (!hasEntered) await p.asked.update(asked => (asked === key ? null : asked));
83}
84
85/** After the screen changed: an ask for a page no longer on screen goes, so its Button comes back there. */
86export async function leaveAsk(p: Ports): Promise<void> {
87 const asked = await p.asked.read();
88 if (asked === null) return;
89 const key = askKey(await p.view.read(), await p.cache.read());
90 if (key !== asked) await p.asked.update(now => (now === asked ? null : now));
91}
92hooks/cache.ts 734 lines1// The session cache: what the agent's own Everythings calls carried and what
2// the pane's reads brought, merged per thing and per workspace. The pane
3// draws from it, so a thing the agent just wrote shows in full even when
4// Claude Code refuses the pane's own read (under auto permission mode it
5// may). Pure functions over EverythingsCache; nav.ts, follow.ts and writes.ts write the
6// result to `$.state`.
7//
8// Bounds: at most THINGS_MAX things, and only the PAGES_KEPT whose pages
9// were seen last keep content and sections; the rest keep a name and place.
10// Each page is bounded on the way in (data.ts), so the cache stays around a
11// megabyte at worst. Each thing also keeps at most 12 marks for its list row
12// (ROW_MARKS_KEPT, a name up to 100 characters and an emoji up to 32): about
13// 2,000 characters a thing, 600,000 over 300 things, only when every mark
14// name is near its limit.
15
16import type {
17 EverythingsCache,
18 EverythingsCachedThing,
19 EverythingsChildren,
20 EverythingsComments,
21 EverythingsDefaultMark,
22 EverythingsMark,
23 EverythingsThingRef,
24 EverythingsView,
25} from '../types';
26import {
27 asRef,
28 commentText,
29 fitGrid,
30 fitPage,
31 isRecord,
32 looksLikeHtml,
33 num,
34 parseComments,
35 parseContent,
36 parseChildren,
37 parseDefaultMarks,
38 parseGridView,
39 parseMarks,
40 parseRefs,
41 parseRequest,
42 parseThing,
43 parseThingView,
44 parseWorkspaces,
45 searchKey,
46 str,
47 toRowMarks,
48 type DrawnGrid,
49 type DrawnHit,
50 type DrawnPage,
51 type DrawnSearch,
52 type GridRecord,
53 type ThingRecord,
54 type ThingViewRecord,
55} from './data';
56
57export const THINGS_MAX = 300;
58export const PAGES_KEPT = 8;
59const ORDER_IDS_MAX = 300;
60const ORDERS_MAX = 50;
61const DELETED_MAX = 200;
62const WORKSPACES_MAX = 200;
63const SEARCHES_MAX = 10;
64
65/** Who a comment the agent added in this session shows as, until a read brings the server's row. */
66export const SESSION_AUTHOR = 'This session';
67/** Who a comment the person added from the pane shows as, until a read brings the server's row. */
68export const PERSON_AUTHOR = 'You';
69
70export const EMPTY_CACHE: EverythingsCache = {
71 tick: 0,
72 things: {},
73 workspaces: [],
74 defaultWorkspaceId: null,
75 order: {},
76 deleted: {},
77 defaultMarks: {},
78 searches: {},
79};
80
81const PAGE_FIELDS: readonly string[] = ['content', 'isContentCut', 'request', 'marks', 'children', 'comments'];
82const NO_CHILDREN: EverythingsChildren = { things: [], count: 0, truncated: false };
83const NO_COMMENTS: EverythingsComments = { list: [], count: 0, truncated: false };
84
85/** A cache being written: one record's worth of changes, stamped with one tick. */
86type Draft = {
87 tick: number;
88 /** A thing recorded after this tick knows better than the record being applied. */
89 since: number;
90 things: Record<string, EverythingsCachedThing>;
91 workspaces: EverythingsCache['workspaces'];
92 defaultWorkspaceId: string | null;
93 order: EverythingsCache['order'];
94 deleted: Record<string, number>;
95 defaultMarks: EverythingsCache['defaultMarks'];
96 searches: EverythingsCache['searches'];
97};
98
99/** `since`: the tick a read started at; an observed call is as new as the cache. */
100function open(cache: EverythingsCache, since = cache.tick): Draft {
101 return {
102 tick: cache.tick + 1,
103 since,
104 things: { ...cache.things },
105 workspaces: [...cache.workspaces],
106 defaultWorkspaceId: cache.defaultWorkspaceId,
107 order: { ...cache.order },
108 deleted: { ...cache.deleted },
109 defaultMarks: { ...cache.defaultMarks },
110 searches: { ...cache.searches },
111 };
112}
113
114/** Merges what a record carried into the cached thing. Fields it did not carry stay as they were. */
115function put(d: Draft, record: ThingRecord): void {
116 const gone = d.deleted[record.id];
117 if (gone !== undefined) {
118 // Deleted after the read began: its answer predates the delete.
119 if (gone > d.since) return;
120 delete d.deleted[record.id];
121 }
122 const old = d.things[record.id];
123 if (!old && record.name === undefined) return;
124 // Something newer was recorded since the read began: fill gaps only.
125 const isStale = old !== undefined && old.seen > d.since;
126 const next: EverythingsCachedThing = old
127 ? { ...old }
128 : { id: record.id, name: record.name ?? 'Untitled', emoji: record.emoji ?? null, seen: 0, pageSeen: 0 };
129 const fields = next as unknown as Record<string, unknown>;
130 let isPage = false;
131 for (const [key, value] of Object.entries(record)) {
132 if (key === 'id' || value === undefined) continue;
133 if (isStale && fields[key] !== undefined) continue;
134 fields[key] = value;
135 if (PAGE_FIELDS.includes(key)) isPage = true;
136 // A page's marks are the freshest the row knows, unless a newer record gave the row its own.
137 if (key === 'marks' && (!isStale || next.rowMarks === undefined)) {
138 next.rowMarks = toRowMarks(value as EverythingsMark[]);
139 }
140 }
141 next.seen = d.tick;
142 if (isPage) {
143 next.pageSeen = d.tick;
144 delete next.isPruned;
145 }
146 d.things[record.id] = next;
147}
148
149/** Changes a known thing's page; an unknown thing is left alone. New marks give its row the same. */
150function patch(d: Draft, id: string, change: (thing: EverythingsCachedThing) => EverythingsCachedThing | null): void {
151 const old = d.things[id];
152 if (!old) return;
153 const next = change({ ...old });
154 if (!next) return;
155 const rowMarks = next.marks && next.marks !== old.marks ? toRowMarks(next.marks) : next.rowMarks;
156 d.things[id] = { ...next, ...(rowMarks ? { rowMarks } : {}), seen: d.tick, pageSeen: d.tick };
157}
158
159/**
160 * A mark write on a thing whose page marks are unknown but whose row marks
161 * are known: "created" adds one, "deleted" takes one away. Only the row
162 * changes; the thing stays as much a page as it was.
163 */
164function patchRow(d: Draft, id: string, name: string, emoji: string | null, step: 1 | -1): void {
165 const old = d.things[id];
166 if (!old?.rowMarks || old.marks) return;
167 const known = old.rowMarks.find(mark => mark.name === name);
168 const rowMarks = known
169 ? old.rowMarks.map(mark => (mark.name === name ? { ...mark, count: mark.count + step } : mark))
170 : step === 1 && emoji
171 ? [...old.rowMarks, { name, emoji, count: 1 }]
172 : old.rowMarks;
173 d.things[id] = { ...old, rowMarks: toRowMarks(rowMarks), seen: d.tick };
174}
175
176function setOrder(d: Draft, workspaceId: string, ids: string[], count: number): void {
177 delete d.order[workspaceId];
178 d.order[workspaceId] = { ids, count };
179}
180
181/** Adds a thing under its parent's sub-things, or to its workspace's listing at the top level. */
182function attach(d: Draft, thing: EverythingsCachedThing): void {
183 if (thing.parentId === null && thing.workspaceId) {
184 const listed = d.order[thing.workspaceId];
185 if (listed && !listed.ids.includes(thing.id)) setOrder(d, thing.workspaceId, [...listed.ids, thing.id], listed.count + 1);
186 } else if (thing.parentId) {
187 patch(d, thing.parentId, parent =>
188 parent.children && !parent.children.things.some(child => child.id === thing.id)
189 ? {
190 ...parent,
191 children: {
192 ...parent.children,
193 things: [...parent.children.things, { id: thing.id, name: thing.name, emoji: thing.emoji }],
194 count: parent.children.count + 1,
195 },
196 }
197 : null,
198 );
199 }
200}
201
202/** Takes a thing out of every sub-things list and listing that holds it. */
203function detach(d: Draft, id: string): void {
204 for (const holder of Object.values(d.things)) {
205 if (holder.children?.things.some(child => child.id === id)) {
206 patch(d, holder.id, parent =>
207 parent.children
208 ? {
209 ...parent,
210 children: {
211 ...parent.children,
212 things: parent.children.things.filter(child => child.id !== id),
213 count: Math.max(0, parent.children.count - 1),
214 },
215 }
216 : null,
217 );
218 }
219 }
220 for (const [workspaceId, listed] of Object.entries(d.order)) {
221 if (listed.ids.includes(id)) {
222 d.order[workspaceId] = { ids: listed.ids.filter(one => one !== id), count: Math.max(0, listed.count - 1) };
223 }
224 }
225}
226
227/** A delete: the thing and what is cached under it go, and stay gone for reads that began before. */
228function remove(d: Draft, id: string, visited = new Set<string>()): void {
229 if (visited.has(id)) return;
230 visited.add(id);
231 for (const child of Object.values(d.things)) {
232 if (child.parentId === id) remove(d, child.id, visited);
233 }
234 detach(d, id);
235 delete d.things[id];
236 d.deleted[id] = d.tick;
237}
238
239function nameWorkspace(d: Draft, id: string | null, name: string | null): void {
240 if (!id || !name) return;
241 const known = d.workspaces.findIndex(ws => ws.id === id);
242 if (known >= 0) d.workspaces[known] = { ...d.workspaces[known]!, name };
243 else d.workspaces.push({ id, name, isDefault: false });
244}
245
246/** A listing of a workspace's top-level things, in the server's order. */
247function applyListing(d: Draft, workspaceId: string, records: ThingRecord[], count: number): void {
248 for (const record of records) put(d, { ...record, parentId: null, workspaceId });
249 const ids = records.map(record => record.id).filter(id => d.things[id] !== undefined);
250 setOrder(d, workspaceId, ids, Math.max(count, ids.length));
251 if (records.length < count) return;
252 // A whole listing: what it left out is no longer at the top level, unless recorded since.
253 for (const thing of Object.values(d.things)) {
254 if (thing.workspaceId === workspaceId && thing.parentId === null && !ids.includes(thing.id) && thing.seen <= d.since) {
255 const { parentId: _gone, ...rest } = thing;
256 d.things[thing.id] = rest;
257 }
258 }
259}
260
261function applyThingView(d: Draft, view: ThingViewRecord): void {
262 const workspaceId = view.workspace?.id ?? view.thing.workspaceId;
263 put(d, view.thing);
264 nameWorkspace(d, view.workspace?.id ?? null, view.workspace?.name ?? null);
265 let parentId: string | null = null;
266 for (const ancestor of view.ancestors) {
267 put(d, { ...ancestor, parentId, ...(workspaceId ? { workspaceId } : {}) });
268 parentId = ancestor.id;
269 }
270 for (const child of view.children.records) {
271 const childWorkspace = child.workspaceId ?? workspaceId;
272 put(d, { ...child, parentId: view.thing.id, ...(childWorkspace ? { workspaceId: childWorkspace } : {}) });
273 }
274}
275
276function applyGrid(d: Draft, grid: GridRecord): void {
277 d.workspaces = grid.workspaces;
278 d.defaultWorkspaceId = grid.defaultWorkspaceId;
279 if (grid.landing) applyListing(d, grid.landing.workspaceId, grid.landing.things, grid.landing.count);
280}
281
282/** Keeps the cache inside its bounds; the thing on screen (`pinned`) is never pruned. */
283function close(d: Draft, pinned: string | null): EverythingsCache {
284 const paged = Object.values(d.things)
285 .filter(thing => thing.pageSeen > 0 && thing.id !== pinned)
286 .sort((a, b) => b.pageSeen - a.pageSeen);
287 const room = PAGES_KEPT - (pinned !== null && (d.things[pinned]?.pageSeen ?? 0) > 0 ? 1 : 0);
288 for (const thing of paged.slice(Math.max(0, room))) {
289 const { content: _c, isContentCut: _x, request: _r, marks: _m, children: _h, comments: _k, ...rest } = thing;
290 d.things[thing.id] = { ...rest, pageSeen: 0, isPruned: true };
291 }
292
293 let excess = Object.keys(d.things).length - THINGS_MAX;
294 if (excess > 0) {
295 const oldest = Object.values(d.things)
296 .filter(thing => thing.id !== pinned)
297 .sort((a, b) => a.seen - b.seen);
298 for (const thing of oldest) {
299 if (excess <= 0) break;
300 delete d.things[thing.id];
301 excess -= 1;
302 }
303 }
304
305 const orderKeys = Object.keys(d.order);
306 for (const key of orderKeys.slice(0, Math.max(0, orderKeys.length - ORDERS_MAX))) delete d.order[key];
307 for (const [key, listed] of Object.entries(d.order)) {
308 if (listed.ids.length > ORDER_IDS_MAX) d.order[key] = { ids: listed.ids.slice(0, ORDER_IDS_MAX), count: listed.count };
309 }
310
311 const deleted = Object.entries(d.deleted)
312 .sort((a, b) => b[1] - a[1])
313 .slice(0, DELETED_MAX);
314
315 return {
316 tick: d.tick,
317 things: d.things,
318 workspaces: d.workspaces.slice(0, WORKSPACES_MAX),
319 defaultWorkspaceId: d.defaultWorkspaceId,
320 order: d.order,
321 deleted: Object.fromEntries(deleted),
322 defaultMarks: Object.fromEntries(Object.entries(d.defaultMarks).slice(-WORKSPACES_MAX)),
323 searches: Object.fromEntries(Object.entries(d.searches).slice(-SEARCHES_MAX)),
324 };
325}
326
327function id(value: unknown): string | null {
328 return str(value, 100);
329}
330
331/** Workspace names the search and recent listings carry beside each thing. */
332function nameWorkspacesOf(d: Draft, items: unknown): void {
333 for (const item of Array.isArray(items) ? items : []) {
334 if (isRecord(item)) nameWorkspace(d, id(item.workspaceId), str(item.workspaceName));
335 }
336}
337
338/**
339 * A search_things answer: its hits go in as names and places, and, for a
340 * query, the list of them in the server's order, as the latest search.
341 */
342function applySearch(d: Draft, query: string, r: Record<string, unknown>): void {
343 const records = parseRefs(r.results);
344 for (const record of records) put(d, record);
345 nameWorkspacesOf(d, r.results);
346 const key = searchKey(query);
347 if (!key) return;
348 const ids = records.map(record => record.id).filter(one => d.things[one] !== undefined);
349 delete d.searches[key];
350 d.searches[key] = { query, ids, count: Math.max(num(r.count, ids.length), ids.length) };
351}
352
353/** A new thing's page, from what create_thing or request_input was given. */
354function created(thingId: string, args: Record<string, unknown>, extra: Partial<ThingRecord>): ThingRecord {
355 const text = typeof args.content === 'string' ? args.content : '';
356 return {
357 id: thingId,
358 name: str(args.name) ?? 'Untitled',
359 emoji: str(args.emoji, 32),
360 parentId: id(args.parentId),
361 ...(id(args.workspaceId) ? { workspaceId: id(args.workspaceId) as string } : {}),
362 // HTML is converted on the server, so its Markdown is unknown here.
363 ...(looksLikeHtml(text) ? {} : parseContent(text)),
364 marks: [],
365 children: NO_CHILDREN,
366 ...extra,
367 };
368}
369
370/**
371 * Records one answered Everythings call: the agent's, or the person's own
372 * mark or comment from the pane. `data` is its JSON answer, null when
373 * unreadable. `author` is who a comment it added shows as: the agent's
374 * shows as SESSION_AUTHOR until a read brings the server's row, the
375 * person's own as PERSON_AUTHOR.
376 */
377export function recordCall(
378 cache: EverythingsCache,
379 name: string,
380 args: Record<string, unknown>,
381 data: Record<string, unknown> | null,
382 pinned: string | null,
383 author = SESSION_AUTHOR,
384): EverythingsCache {
385 const d = open(cache);
386 const r = data ?? {};
387 const thingId = id(args.thingId);
388
389 switch (name) {
390 case 'get_thing_view': {
391 const view = parseThingView(r);
392 if (view) applyThingView(d, view);
393 break;
394 }
395 case 'get_workspace_view': {
396 const grid = parseGridView(r);
397 if (grid) applyGrid(d, grid);
398 break;
399 }
400 case 'get_thing': {
401 const thing = parseThing(r);
402 if (thing) put(d, thing);
403 break;
404 }
405 case 'list_workspaces': {
406 if (Array.isArray(r.workspaces)) d.workspaces = parseWorkspaces(r.workspaces);
407 if ('defaultWorkspaceId' in r) d.defaultWorkspaceId = id(r.defaultWorkspaceId);
408 break;
409 }
410 case 'get_workspace': {
411 const workspaceId = id(r.id);
412 nameWorkspace(d, workspaceId, str(r.name));
413 const marks = parseDefaultMarks(r);
414 if (workspaceId && marks) d.defaultMarks[workspaceId] = marks;
415 break;
416 }
417 case 'list_things': {
418 const workspaceId = id(args.workspaceId);
419 const records = parseRefs(r.things);
420 if (workspaceId) applyListing(d, workspaceId, records, num(r.count, records.length));
421 break;
422 }
423 case 'list_child_things': {
424 const parentId = id(args.parentId);
425 if (!parentId) break;
426 const { records, section } = parseChildren(r);
427 for (const record of records) put(d, { ...record, parentId });
428 put(d, { id: parentId, children: section });
429 break;
430 }
431 case 'search_things':
432 applySearch(d, typeof args.query === 'string' ? args.query : '', r);
433 break;
434 case 'recent_things':
435 for (const record of parseRefs(r.things)) put(d, record);
436 nameWorkspacesOf(d, r.things);
437 break;
438 case 'list_comments':
439 if (thingId) put(d, { id: thingId, comments: parseComments(r) });
440 break;
441 case 'list_marks':
442 if (thingId) put(d, { id: thingId, marks: parseMarks(r) });
443 break;
444 case 'create_thing': {
445 const newId = id(r.thingId);
446 if (!newId) break;
447 put(d, created(newId, { ...args, workspaceId: r.workspaceId ?? args.workspaceId }, { request: null, comments: NO_COMMENTS }));
448 const thing = d.things[newId];
449 if (thing) attach(d, thing);
450 break;
451 }
452 case 'update_thing': {
453 const updatedId = id(r.thingId) ?? thingId;
454 if (!updatedId) break;
455 put(d, {
456 id: updatedId,
457 ...('name' in r && str(r.name) ? { name: str(r.name) as string } : {}),
458 ...('emoji' in r ? { emoji: str(r.emoji, 32) } : {}),
459 ...('content' in r ? parseContent(r.content) : {}),
460 });
461 break;
462 }
463 case 'delete_thing':
464 if (thingId) remove(d, thingId);
465 break;
466 case 'restore_thing':
467 if (thingId) delete d.deleted[thingId];
468 break;
469 case 'move_thing': {
470 if (!thingId) break;
471 const parentId = 'newParentId' in r ? id(r.newParentId) : id(args.newParentId);
472 detach(d, thingId);
473 put(d, { id: thingId, parentId });
474 const thing = d.things[thingId];
475 if (thing) attach(d, thing);
476 break;
477 }
478 case 'copy_thing': {
479 const copyId = id(r.thingId);
480 if (!copyId) break;
481 const source = thingId ? d.things[thingId] : undefined;
482 const copy: ThingRecord = {
483 id: copyId,
484 name: str(r.name) ?? source?.name ?? 'Untitled',
485 emoji: source?.emoji ?? null,
486 parentId: id(args.targetParentId),
487 ...(id(args.targetWorkspaceId) ? { workspaceId: id(args.targetWorkspaceId) as string } : {}),
488 // A copy carries content, but no marks or comments.
489 ...(source?.content !== undefined ? { content: source.content, isContentCut: source.isContentCut === true } : {}),
490 marks: [],
491 comments: NO_COMMENTS,
492 ...(num(r.copiedCount, 0) === 1 ? { children: NO_CHILDREN } : {}),
493 };
494 put(d, copy);
495 const thing = d.things[copyId];
496 if (thing) attach(d, thing);
497 break;
498 }
499 case 'add_mark': {
500 // The call rides the person's token, so the mark is theirs after it,
501 // "created" or "unchanged" alike. The count goes up only for a mark
502 // the cache did not have as theirs: a read that came back while the
503 // call ran may have counted it already.
504 const markName = str(args.name, 100) ?? str(args.emoji, 32);
505 const emoji = str(args.emoji, 32);
506 const isCreated = r.action === 'created';
507 if (!thingId || !markName || !emoji || (!isCreated && r.action !== 'unchanged')) break;
508 if (isCreated) patchRow(d, thingId, markName, emoji, 1);
509 patch(d, thingId, thing => {
510 if (!thing.marks) return null;
511 const known = thing.marks.find(mark => mark.name === markName);
512 if (!known) return { ...thing, marks: [...thing.marks, { name: markName, emoji, count: 1, mine: true }] };
513 if (known.mine) return null;
514 return {
515 ...thing,
516 marks: thing.marks.map(mark =>
517 mark.name === markName ? { ...mark, count: isCreated ? mark.count + 1 : mark.count, mine: true } : mark,
518 ),
519 };
520 });
521 break;
522 }
523 case 'remove_mark': {
524 // The mark is not theirs after it, "deleted" or "unchanged" alike. The
525 // count goes down only for a mark the cache still had as theirs.
526 const markName = str(args.name, 100) ?? str(args.emoji, 32);
527 const isDeleted = r.action === 'deleted';
528 if (!thingId || !markName || (!isDeleted && r.action !== 'unchanged')) break;
529 if (isDeleted) patchRow(d, thingId, markName, null, -1);
530 patch(d, thingId, thing => {
531 const known = thing.marks?.find(mark => mark.name === markName);
532 if (!thing.marks || !known || !known.mine) return null;
533 return {
534 ...thing,
535 marks: thing.marks
536 .map(mark =>
537 mark.name === markName ? { ...mark, count: isDeleted ? mark.count - 1 : mark.count, mine: false } : mark,
538 )
539 .filter(mark => mark.count > 0),
540 };
541 });
542 break;
543 }
544 case 'add_comment': {
545 const commentId = id(r.commentId);
546 if (!thingId || !commentId) break;
547 const text = commentText(args.content);
548 patch(d, thingId, thing =>
549 thing.comments
550 ? {
551 ...thing,
552 comments: {
553 ...thing.comments,
554 list: [...thing.comments.list, { id: commentId, authorName: author, byAgent: null, parentId: null, text }],
555 count: thing.comments.count + 1,
556 },
557 }
558 : null,
559 );
560 break;
561 }
562 case 'request_input': {
563 const askedId = id(r.thingId) ?? thingId;
564 if (!askedId) break;
565 // Without a thingId the call made a new thing to carry the question.
566 if (!thingId) {
567 put(d, created(askedId, args, {}));
568 const thing = d.things[askedId];
569 if (thing) attach(d, thing);
570 }
571 if (isRecord(r.request)) put(d, { id: askedId, request: parseRequest(r.request) });
572 break;
573 }
574 case 'answer_request':
575 case 'resolve_request':
576 if (thingId && isRecord(r.request)) put(d, { id: thingId, request: parseRequest(r.request) });
577 break;
578 case 'resolve_mention': {
579 const onThing = id(r.thingId);
580 const commentId = id(r.commentId);
581 const replyId = id(r.replyId);
582 if (!onThing || !commentId || r.action !== 'resolved') break;
583 patch(d, onThing, thing => {
584 if (!thing.comments) return null;
585 const list = thing.comments.list.map(comment =>
586 comment.id === commentId ? { ...comment, text: comment.text.replace('@Agent', '🤖agent') } : comment,
587 );
588 const reply = replyId
589 ? [{ id: replyId, authorName: SESSION_AUTHOR, byAgent: null, parentId: commentId, text: commentText(args.reply) }]
590 : [];
591 return { ...thing, comments: { ...thing.comments, list: [...list, ...reply], count: thing.comments.count + reply.length } };
592 });
593 break;
594 }
595 default:
596 return cache;
597 }
598 return close(d, pinned);
599}
600
601/** The pane's own get_thing_view answer, read from tick `since` on. */
602export function recordPage(cache: EverythingsCache, view: ThingViewRecord, since: number, pinned: string | null): EverythingsCache {
603 const d = open(cache, since);
604 applyThingView(d, view);
605 return close(d, pinned);
606}
607
608/** A workspace's default marks, from the pane's own get_workspace answer. */
609export function recordDefaultMarks(
610 cache: EverythingsCache,
611 workspaceId: string,
612 marks: EverythingsDefaultMark[],
613): EverythingsCache {
614 const known = cache.defaultMarks[workspaceId];
615 if (known && JSON.stringify(known) === JSON.stringify(marks)) return cache;
616 return { ...cache, defaultMarks: { ...cache.defaultMarks, [workspaceId]: marks } };
617}
618
619/** The pane's own get_workspace_view answer, read from tick `since` on. */
620export function recordGrid(cache: EverythingsCache, grid: GridRecord, since: number, pinned: string | null): EverythingsCache {
621 const d = open(cache, since);
622 applyGrid(d, grid);
623 return close(d, pinned);
624}
625
626/** The pane's own search_things answer for `query`, read from tick `since` on. */
627export function recordSearch(
628 cache: EverythingsCache,
629 query: string,
630 data: Record<string, unknown>,
631 since: number,
632 pinned: string | null,
633): EverythingsCache {
634 const d = open(cache, since);
635 applySearch(d, query, data);
636 return close(d, pinned);
637}
638
639/**
640 * The hits recorded for one query, recorded for another too: the agent ran
641 * the search the pane asked for in its own words.
642 */
643export function aliasSearch(cache: EverythingsCache, from: string, to: string): EverythingsCache {
644 const found = cache.searches[searchKey(from)];
645 const key = searchKey(to);
646 if (!found || !key || searchKey(from) === key) return cache;
647 const { [key]: _old, ...rest } = cache.searches;
648 return { ...cache, searches: { ...rest, [key]: found } };
649}
650
651/** A search's hits as known now; null while no answer for the query was recorded. */
652export function searchOf(cache: EverythingsCache, query: string): DrawnSearch | null {
653 const found = cache.searches[searchKey(query)];
654 if (!found) return null;
655 const hits: DrawnHit[] = found.ids.flatMap(one => {
656 const thing = cache.things[one];
657 if (!thing) return [];
658 const workspace = thing.workspaceId ? cache.workspaces.find(ws => ws.id === thing.workspaceId) : undefined;
659 return [
660 {
661 ...asRef(thing),
662 workspaceId: thing.workspaceId ?? null,
663 workspaceName: workspace?.name ?? null,
664 marks: thing.rowMarks ?? null,
665 },
666 ];
667 });
668 return { query: found.query, hits, count: found.count, truncated: hits.length < found.count };
669}
670
671/** One thing's page as known now; null when the session has seen nothing of it. */
672export function pageOf(cache: EverythingsCache, thingId: string): DrawnPage | null {
673 const thing = cache.things[thingId];
674 if (!thing) return null;
675 const workspace = thing.workspaceId ? cache.workspaces.find(ws => ws.id === thing.workspaceId) : undefined;
676 const parent = thing.parentId ? cache.things[thing.parentId] : undefined;
677 const defaults = thing.workspaceId ? (cache.defaultMarks[thing.workspaceId] ?? null) : null;
678 return fitPage(thing, workspace?.name ?? null, parent ? asRef(parent) : null, defaults, id => cache.things[id]?.rowMarks);
679}
680
681/**
682 * True when the cache holds a thing's whole page, as a get_thing_view
683 * answer leaves it: content and every section.
684 */
685export function isWholePage(cache: EverythingsCache, thingId: string): boolean {
686 const thing = cache.things[thingId];
687 return (
688 thing !== undefined &&
689 thing.content !== undefined &&
690 thing.marks !== undefined &&
691 thing.children !== undefined &&
692 thing.comments !== undefined
693 );
694}
695
696/** What a view draws from the cache, as one string: two caches that give the same draw the same. */
697export function drawnOf(cache: EverythingsCache, view: EverythingsView): string {
698 if (view.kind === 'search') return JSON.stringify(searchOf(cache, view.query));
699 if (view.kind === 'inbox') return '';
700 return JSON.stringify(view.kind === 'thing' ? pageOf(cache, view.thingId) : gridOf(cache, view.workspaceId));
701}
702
703/** The workspace a grid with none named lands on: the default, else the first known. */
704export function landingOf(cache: EverythingsCache, workspaceId: string | null): string | null {
705 if (workspaceId) return workspaceId;
706 if (cache.defaultWorkspaceId) return cache.defaultWorkspaceId;
707 if (cache.workspaces[0]) return cache.workspaces[0].id;
708 const newest = Object.values(cache.things)
709 .filter(thing => thing.workspaceId)
710 .sort((a, b) => b.seen - a.seen)[0];
711 return newest?.workspaceId ?? null;
712}
713
714/** A workspace's grid as known now: the listing's order first, then top-level things seen since. */
715export function gridOf(cache: EverythingsCache, workspaceId: string | null): DrawnGrid {
716 const landing = landingOf(cache, workspaceId);
717 if (!landing) return fitGrid(cache.workspaces, null);
718 const listed = cache.order[landing];
719 const isTop = (thing: EverythingsCachedThing | undefined): thing is EverythingsCachedThing =>
720 thing !== undefined && thing.workspaceId === landing && thing.parentId === null;
721 const inOrder = (listed?.ids ?? []).map(one => cache.things[one]).filter(isTop);
722 const placed = new Set(inOrder.map(thing => thing.id));
723 const others = Object.values(cache.things)
724 .filter(thing => isTop(thing) && !placed.has(thing.id))
725 .sort((a, b) => a.seen - b.seen);
726 const things: EverythingsThingRef[] = [...inOrder, ...others].map(asRef);
727 return fitGrid(cache.workspaces, {
728 workspaceId: landing,
729 things,
730 count: Math.max(listed?.count ?? 0, things.length),
731 isListed: listed !== undefined,
732 });
733}
734hooks/data.ts 766 lines1// Pure helpers: tool names, payload parsing, and the text bounds the
2// surfaces enforce. Nothing here touches `$`.
3//
4// Payload shapes follow apps/web/src/app/api/mcp/route.ts. A parser keeps
5// only what the payload carried: a field it did not carry stays undefined,
6// so the cache (cache.ts) knows it is unknown rather than empty.
7
8import type { McpToolResult, ToolCallResult } from 'claude-code';
9
10import type {
11 EverythingsCachedThing,
12 EverythingsChildren,
13 EverythingsComment,
14 EverythingsComments,
15 EverythingsDefaultMark,
16 EverythingsInbox,
17 EverythingsLoad,
18 EverythingsRowMark,
19 EverythingsMark,
20 EverythingsRequest,
21 EverythingsThingRef,
22 EverythingsView,
23 EverythingsWake,
24 EverythingsWorkspace,
25 EverythingsWrites,
26} from '../types';
27
28/** The pane's id: `$.ui.open`, `$.ui.panes` and the Pane render matcher. */
29export const PANE_ID = 'everythings';
30export const PANE_TITLE = 'Everythings';
31
32export const APP_ORIGIN = 'https://www.everythings.app';
33
34/** What a thing with no emoji shows, as on the web and the phone (`thing.emoji || '📄'`). */
35export const NO_EMOJI = '📄';
36
37export const INITIAL_VIEW: EverythingsView = { kind: 'grid', workspaceId: null };
38export const IDLE_LOAD: EverythingsLoad = {
39 seq: 0,
40 key: '',
41 isLoading: false,
42 error: null,
43 isNotConnected: false,
44};
45
46/** Markdown and Text both refuse a string longer than this. */
47export const TEXT_MAX = 10_000;
48/**
49 * The engine refuses a drawing holding more than 100,000 characters of text,
50 * counting every Text, label, Markdown text, link target and pressable link
51 * (keys are free). A page keeps its own strings under this budget, and
52 * PAGE_CHROME reserves room for what the pane adds around them: the header,
53 * the status lines, notes and labels.
54 */
55const PAGE_TEXT_BUDGET = 95_000;
56const PAGE_CHROME = 2_000;
57const COMMENT_MAX = 2_000;
58const COMMENTS_KEPT = 100;
59/** Comment text kept per thing; more could never fit a page. */
60const COMMENTS_TEXT_KEPT = 60_000;
61const NAME_MAX = 200;
62
63/**
64 * The reads the pane makes of its own: a grid, a thing's page, a workspace's
65 * default marks, and the search `/findthing` runs.
66 */
67export const READ_TOOLS = {
68 grid: 'get_workspace_view',
69 thing: 'get_thing_view',
70 defaults: 'get_workspace',
71 search: 'search_things',
72} as const;
73
74/**
75 * The band's reads (inbox.ts): open questions, and open @Agent jobs. The wake
76 * poll calls list_requests too, for answered ones.
77 */
78export const INBOX_TOOLS = { questions: 'list_requests', jobs: 'list_mentions' } as const;
79
80export const IDLE_INBOX: EverythingsInbox = { questions: null, jobs: null, isRefused: false };
81export const IDLE_WAKE: EverythingsWake = { waits: [], woke: [], isStopped: false };
82
83/**
84 * The writes the pane makes, each only on the person's own action: their
85 * mark on a press, their comment on a submit. It calls no other tool.
86 */
87export const WRITE_TOOLS = { addMark: 'add_mark', removeMark: 'remove_mark', comment: 'add_comment' } as const;
88
89export const IDLE_WRITES: EverythingsWrites = {
90 pending: {},
91 commenting: {},
92 posted: {},
93 error: null,
94 blocked: null,
95 last: null,
96};
97
98/** A mark or comment marker older than this is one an earlier load left: it blocks nothing. */
99export const WRITE_STALE_MS = 30_000;
100
101/** The most a comment holds (TEXT_LIMITS.commentContent on the server). */
102export const COMMENT_LIMIT = 10_000;
103
104/**
105 * Tools of the Everythings MCP server (apps/web/src/app/api/mcp/route.ts)
106 * whose names only it uses. An answered call of one names the server: the
107 * pane reads from that server from then on, in this session and later ones.
108 */
109export const UNIQUE_TOOLS: ReadonlySet<string> = new Set([
110 'what_changed', 'my_reception', 'search_things', 'get_thing', 'get_thing_view',
111 'get_workspace_view', 'list_things', 'list_child_things', 'recent_things',
112 'create_thing', 'update_thing', 'delete_thing', 'restore_thing', 'move_thing',
113 'reorder_things', 'copy_thing', 'claim_thing', 'release_thing', 'request_input',
114 'answer_request', 'resolve_request', 'list_requests',
115]);
116
117/**
118 * Its tools whose names another server may use too (a Notion `search`, a
119 * Linear `add_comment`). A call of one counts as an Everythings call only on
120 * the server already learned.
121 */
122export const GENERIC_TOOLS: ReadonlySet<string> = new Set([
123 'search', 'fetch', 'list_workspaces', 'get_workspace', 'create_workspace',
124 'rename_workspace', 'list_comments', 'list_marks', 'list_trash', 'add_comment',
125 'add_mark', 'remove_mark', 'list_mentions', 'resolve_mention',
126]);
127
128/** The Everythings tools that only read. An answered call of any other is a write. */
129export const READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
130 'what_changed', 'my_reception', 'search_things', 'get_thing', 'get_thing_view',
131 'get_workspace_view', 'list_things', 'list_child_things', 'recent_things', 'list_requests',
132 'search', 'fetch', 'list_workspaces', 'get_workspace', 'list_comments', 'list_marks',
133 'list_trash', 'list_mentions',
134]);
135
136/** The writes the pane follows. */
137export const FOLLOWED_WRITES: ReadonlySet<string> = new Set([
138 'create_thing', 'update_thing', 'move_thing', 'delete_thing', 'restore_thing',
139 'add_mark', 'remove_mark', 'add_comment', 'request_input', 'answer_request',
140 'resolve_request', 'copy_thing', 'resolve_mention',
141]);
142
143/**
144 * Writes whose thing id is in the result: the new thing, the copy, the thing
145 * a question was asked on, the thing of the comment a mention was in.
146 */
147const ID_FROM_RESULT: ReadonlySet<string> = new Set([
148 'create_thing', 'copy_thing', 'request_input', 'resolve_mention',
149]);
150
151/** What a followed write asks of the pane. */
152export type WriteTarget = { kind: 'open'; thingId: string } | { kind: 'deleted'; thingId: string };
153
154export function viewKey(view: EverythingsView): string {
155 if (view.kind === 'search') return `search:${searchKey(view.query)}`;
156 if (view.kind === 'inbox') return `inbox:${view.list}`;
157 return view.kind === 'grid' ? `grid:${view.workspaceId ?? ''}` : `thing:${view.thingId}`;
158}
159
160/** The most of a query the pane keeps, sends and shows. */
161export const QUERY_MAX = 200;
162
163/** A query as the person typed it, on one line and in bounds; empty when they typed none. */
164export function cleanQuery(value: unknown): string {
165 return clean(value, QUERY_MAX).replace(/\s+/g, ' ').trim();
166}
167
168/** What makes two queries the same search: case and spacing aside. */
169export function searchKey(query: string): string {
170 return cleanQuery(query).toLowerCase();
171}
172
173export function thingUrl(workspaceId: string, thingId: string): string {
174 return `${APP_ORIGIN}/workspaces/${encodeURIComponent(workspaceId)}/things/${encodeURIComponent(thingId)}`;
175}
176
177/**
178 * Splits `mcp__<server>__<tool>` at its last `__`. In the desktop app the
179 * Everythings connector runs under a UUID; in a terminal under `everythings`
180 * or `claude_ai_Everythings`. Either way the server part is a name
181 * `$.mcp.call` takes.
182 */
183export function splitToolName(tool: string): { server: string; name: string } | null {
184 if (!tool.startsWith('mcp__')) return null;
185 const rest = tool.slice('mcp__'.length);
186 const at = rest.lastIndexOf('__');
187 if (at <= 0) return null;
188 const server = rest.slice(0, at);
189 const name = rest.slice(at + 2);
190 return server && name ? { server, name } : null;
191}
192
193export function isAnswered(ran: ToolCallResult): boolean {
194 return ran.deny === undefined && ran.isError !== true;
195}
196
197export function isRecord(value: unknown): value is Record<string, unknown> {
198 return typeof value === 'object' && value !== null && !Array.isArray(value);
199}
200
201function parseJson(text: string): unknown {
202 try {
203 return JSON.parse(text);
204 } catch {
205 return undefined;
206 }
207}
208
209function blocksJson(blocks: unknown[]): unknown {
210 const text = blocks.map(block => (isRecord(block) && typeof block.text === 'string' ? block.text : '')).join('');
211 return parseJson(text);
212}
213
214/**
215 * A tool call's JSON answer, wherever the engine put it: the result record
216 * itself, its structured content, its content blocks, or the text the model
217 * read. Null when none of them holds a JSON object.
218 */
219export function answerData(ran: ToolCallResult): Record<string, unknown> | null {
220 return outputData(ran.result, ran.text);
221}
222
223/**
224 * The JSON answer in a tool's stored result (`ToolCallResult.result`, a
225 * transcript row's `output`), else in the text the model read. Null when
226 * neither holds a JSON object.
227 */
228export function outputData(result: unknown, text?: unknown): Record<string, unknown> | null {
229 const candidates: unknown[] = [];
230 if (isRecord(result)) {
231 candidates.push(result.structuredContent);
232 if (Array.isArray(result.content)) candidates.push(blocksJson(result.content));
233 else candidates.push(result);
234 }
235 if (Array.isArray(result)) candidates.push(blocksJson(result));
236 if (typeof result === 'string') candidates.push(parseJson(result));
237 if (typeof text === 'string') candidates.push(parseJson(text));
238 for (const one of candidates) if (isRecord(one)) return one;
239 return null;
240}
241
242/** Which thing a followed write is about, or null when it names none. */
243export function writeTarget(
244 name: string,
245 args: Record<string, unknown>,
246 data: Record<string, unknown> | null,
247): WriteTarget | null {
248 const fromArgs = typeof args.thingId === 'string' && args.thingId ? args.thingId : null;
249 const fromResult = data && typeof data.thingId === 'string' && data.thingId ? data.thingId : null;
250 const thingId = ID_FROM_RESULT.has(name) ? (fromResult ?? fromArgs) : fromArgs;
251 if (!thingId) return null;
252 return name === 'delete_thing' ? { kind: 'deleted', thingId } : { kind: 'open', thingId };
253}
254
255/** An MCP result as JSON data, or the server's error text as one line. */
256export function readResult(result: McpToolResult): { data: Record<string, unknown> } | { error: string } {
257 const text = result.content
258 .map(block => (typeof block.text === 'string' ? block.text : ''))
259 .join('\n')
260 .trim();
261 if (result.isError) return { error: oneLine(text) || 'The Everythings server reported an error.' };
262 if (isRecord(result.structuredContent)) return { data: result.structuredContent };
263 const data = parseJson(text);
264 return isRecord(data) ? { data } : { error: 'The Everythings server sent a reply the pane cannot read.' };
265}
266
267/** Strips control characters (tab and newline survive when `isMultiline`). */
268export function clean(value: unknown, max: number, isMultiline = false): string {
269 if (typeof value !== 'string') return '';
270 const text = value
271 .replace(/\r\n?/g, '\n')
272 .replace(isMultiline ? /[\u0000-\u0008\u000b-\u001f\u007f]/g : /[\u0000-\u001f\u007f]/g, ' ');
273 return cut(text, max);
274}
275
276/** Cuts at `max` UTF-16 units without splitting a surrogate pair. */
277function cut(text: string, max: number): string {
278 if (text.length <= max) return text;
279 const code = text.charCodeAt(max - 1);
280 return text.slice(0, code >= 0xd800 && code <= 0xdbff ? max - 1 : max);
281}
282
283/** Cells one character takes on a terminal: 2 for wide CJK and most emoji, else 1. */
284function cellsOf(char: string): number {
285 const code = char.codePointAt(0) ?? 0;
286 const isWide =
287 (code >= 0x1100 && code <= 0x115f) ||
288 (code >= 0x2e80 && code <= 0xa4cf) ||
289 (code >= 0xac00 && code <= 0xd7a3) ||
290 (code >= 0xf900 && code <= 0xfaff) ||
291 (code >= 0xfe30 && code <= 0xfe4f) ||
292 (code >= 0xff00 && code <= 0xff60) ||
293 (code >= 0xffe0 && code <= 0xffe6) ||
294 (code >= 0x1f300 && code <= 0x1faff) ||
295 (code >= 0x20000 && code <= 0x3fffd);
296 return isWide ? 2 : 1;
297}
298
299/** `text` cut to fit `cells` terminal cells, ending in an ellipsis when cut. */
300export function clip(text: string, cells: number): string {
301 const chars = Array.from(text);
302 if (chars.reduce((sum, char) => sum + cellsOf(char), 0) <= cells) return text;
303 let kept = '';
304 let used = 0;
305 for (const char of chars) {
306 used += cellsOf(char);
307 if (used > cells - 1) break;
308 kept += char;
309 }
310 return `${kept.trimEnd()}…`;
311}
312
313export function oneLine(text: string): string {
314 return clean(text.split('\n').find(line => line.trim()) ?? '', 300).trim();
315}
316
317export function str(value: unknown, max = NAME_MAX): string | null {
318 const text = clean(value, max).trim();
319 return text ? text : null;
320}
321
322export function num(value: unknown, fallback: number): number {
323 return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
324}
325
326function list(value: unknown): unknown[] {
327 return Array.isArray(value) ? value : [];
328}
329
330/** A thing as one payload carried it; a field it did not carry is undefined. */
331export type ThingRecord = Omit<EverythingsCachedThing, 'name' | 'emoji' | 'seen' | 'pageSeen'> & {
332 name?: string;
333 emoji?: string | null;
334};
335
336/** A thing in a listing: its id, name and emoji, and where it sits when the listing says. */
337export function parseRef(item: unknown): ThingRecord | null {
338 if (!isRecord(item)) return null;
339 const id = str(item.id, 100);
340 if (!id) return null;
341 const record: ThingRecord = { id, name: str(item.name) ?? 'Untitled', emoji: str(item.emoji, 32) };
342 const workspaceId = str(item.workspaceId, 100);
343 if (workspaceId) record.workspaceId = workspaceId;
344 if ('parentId' in item) record.parentId = str(item.parentId, 100);
345 const marks = parseRowMarks(item.marks);
346 if (marks !== null) record.rowMarks = marks;
347 return record;
348}
349
350/** At most this many marks are kept per thing for its list row. */
351export const ROW_MARKS_KEPT = 12;
352
353/** Marks as a list row keeps them: each with a count, at most ROW_MARKS_KEPT. */
354export function toRowMarks(marks: readonly { name: string; emoji: string; count: number }[]): EverythingsRowMark[] {
355 return marks
356 .filter(mark => mark.count > 0)
357 .slice(0, ROW_MARKS_KEPT)
358 .map(mark => ({ name: mark.name, emoji: mark.emoji, count: mark.count }));
359}
360
361/** A listed thing's `marks`, which the view tools carry; null when the answer has none (the older shape). */
362export function parseRowMarks(value: unknown): EverythingsRowMark[] | null {
363 if (!Array.isArray(value)) return null;
364 return toRowMarks(
365 value.flatMap(item => {
366 if (!isRecord(item)) return [];
367 const name = str(item.name, 100);
368 const emoji = str(item.emoji, 32);
369 return name && emoji ? [{ name, emoji, count: num(item.count, 1) }] : [];
370 }),
371 );
372}
373
374export function parseRefs(value: unknown): ThingRecord[] {
375 return list(value).flatMap(item => parseRef(item) ?? []);
376}
377
378export function asRef(record: ThingRecord): EverythingsThingRef {
379 return { id: record.id, name: record.name ?? 'Untitled', emoji: record.emoji ?? null };
380}
381
382export function parseWorkspaces(value: unknown): EverythingsWorkspace[] {
383 return list(value).flatMap(item => {
384 if (!isRecord(item)) return [];
385 const id = str(item.id, 100);
386 return id ? [{ id, name: str(item.name) ?? 'Untitled', isDefault: item.isDefault === true }] : [];
387 });
388}
389
390/** Thing content as the pane keeps it: at most 10,000 characters, null when empty. */
391export function parseContent(value: unknown): { content: string | null; isContentCut: boolean } {
392 const raw = typeof value === 'string' ? clean(value, Number.MAX_SAFE_INTEGER, true) : '';
393 const kept = clean(raw, TEXT_MAX, true);
394 return { content: kept.trim() ? kept : null, isContentCut: kept.length < raw.length };
395}
396
397export function parseRequest(value: unknown): EverythingsRequest | null {
398 if (!isRecord(value)) return null;
399 const question = str(value.question, 2_000);
400 if (!question) return null;
401 const answer = isRecord(value.answer) ? value.answer : null;
402 return {
403 question,
404 options: list(value.options).flatMap(option => {
405 if (!isRecord(option)) return [];
406 const label = str(option.label);
407 return label
408 ? [{ id: str(option.id, 100) ?? label, label, description: str(option.description, 500) }]
409 : [];
410 }),
411 status: str(value.status, 32) ?? 'open',
412 createdBy: str(value.createdBy, 100),
413 answer: answer
414 ? {
415 optionId: str(answer.optionId, 100),
416 optionLabel: str(answer.optionLabel),
417 comment: str(answer.comment, 2_000),
418 userName: str(answer.userName, 100),
419 via: str(answer.via, 16),
420 }
421 : null,
422 };
423}
424
425export function parseMarks(value: unknown): EverythingsMark[] {
426 const marks = isRecord(value) ? value.marks : value;
427 return list(marks).flatMap(item => {
428 if (!isRecord(item)) return [];
429 const name = str(item.name, 100);
430 const emoji = str(item.emoji, 32);
431 return name && emoji ? [{ name, emoji, count: num(item.count, 1), mine: item.mine === true }] : [];
432 });
433}
434
435/** get_workspace's `defaultMarks`, at most 8 as the workspace keeps them; null when the answer has none. */
436export function parseDefaultMarks(data: unknown): EverythingsDefaultMark[] | null {
437 if (!isRecord(data) || !Array.isArray(data.defaultMarks)) return null;
438 return data.defaultMarks.slice(0, 8).flatMap(item => {
439 if (!isRecord(item)) return [];
440 const name = str(item.name, 100);
441 const emoji = str(item.emoji, 32);
442 return name && emoji ? [{ name, emoji }] : [];
443 });
444}
445
446/** Mention tokens drawn as `@Name` (packages/shared/src/mentions.ts). */
447const MENTION_TOKEN = /@\[([^\]\n]*)\]\((user:[^)\s]+|agent)\)/g;
448
449export function commentText(value: unknown): string {
450 return clean(String(value ?? '').replace(MENTION_TOKEN, '@$1'), COMMENT_MAX, true).trim();
451}
452
453/** Comments in order, as many as the cache keeps. */
454export function parseComments(value: unknown): EverythingsComments {
455 const page = isRecord(value) ? value : {};
456 const all = list(page.comments);
457 const kept: EverythingsComment[] = [];
458 let room = COMMENTS_TEXT_KEPT;
459 for (const item of all) {
460 if (!isRecord(item)) continue;
461 if (kept.length >= COMMENTS_KEPT) break;
462 const id = str(item.id, 100);
463 if (!id) continue;
464 const text = commentText(item.content);
465 if (text.length > room) break;
466 room -= text.length;
467 kept.push({
468 id,
469 authorName: str(item.authorName, 100) ?? 'Someone',
470 byAgent: str(item.byAgent, 100),
471 parentId: str(item.parentId, 100),
472 text,
473 });
474 }
475 const count = num(page.count, all.length);
476 return { list: kept, count, truncated: page.truncated === true || kept.length < count };
477}
478
479export function parseChildren(value: unknown): { records: ThingRecord[]; section: EverythingsChildren } {
480 const page = isRecord(value) ? value : {};
481 const records = parseRefs(page.things);
482 const count = num(page.count, records.length);
483 return {
484 records,
485 section: { things: records.map(asRef), count, truncated: page.truncated === true || records.length < count },
486 };
487}
488
489/** get_thing's answer, or the `thing` of get_thing_view's. */
490export function parseThing(value: unknown): ThingRecord | null {
491 if (!isRecord(value)) return null;
492 const id = str(value.id, 100);
493 if (!id) return null;
494 return {
495 id,
496 name: str(value.name) ?? 'Untitled',
497 emoji: str(value.emoji, 32),
498 ...parseContent(value.content),
499 ...(str(value.workspaceId, 100) ? { workspaceId: str(value.workspaceId, 100) as string } : {}),
500 ...('parentId' in value ? { parentId: str(value.parentId, 100) } : {}),
501 agent: str(value.updatedByAgent, 100) ?? str(value.createdByAgent, 100),
502 request: parseRequest(value.request),
503 };
504}
505
506/** get_thing_view's answer: the whole page of one thing. */
507export type ThingViewRecord = {
508 thing: ThingRecord;
509 workspace: { id: string; name: string } | null;
510 ancestors: ThingRecord[];
511 children: ReturnType<typeof parseChildren>;
512};
513
514export function parseThingView(data: unknown): ThingViewRecord | null {
515 if (!isRecord(data)) return null;
516 const thing = parseThing(data.thing);
517 if (!thing) return null;
518 const space = isRecord(data.workspace) ? data.workspace : {};
519 const workspaceId = str(space.id, 100) ?? thing.workspaceId ?? null;
520 const children = parseChildren(data.children);
521 return {
522 thing: { ...thing, marks: parseMarks(data.marks), comments: parseComments(data.comments), children: children.section },
523 workspace: workspaceId ? { id: workspaceId, name: str(space.name) ?? 'Workspace' } : null,
524 ancestors: parseRefs(data.ancestors),
525 children,
526 };
527}
528
529/** get_workspace_view's answer: the workspaces and the landing workspace's top-level things. */
530export type GridRecord = {
531 workspaces: EverythingsWorkspace[];
532 defaultWorkspaceId: string | null;
533 landing: { workspaceId: string; things: ThingRecord[]; count: number } | null;
534};
535
536export function parseGridView(data: unknown): GridRecord | null {
537 if (!isRecord(data) || !Array.isArray(data.workspaces)) return null;
538 const landing = isRecord(data.landing) ? data.landing : null;
539 const landingId = landing ? str(landing.workspaceId, 100) : null;
540 const things = landing ? parseRefs(landing.things) : [];
541 const count = landing ? num(landing.count, things.length) : 0;
542 return {
543 workspaces: parseWorkspaces(data.workspaces),
544 defaultWorkspaceId: str(data.defaultWorkspaceId, 100),
545 landing: landing && landingId ? { workspaceId: landingId, things, count } : null,
546 };
547}
548
549/** A search hit as its row draws it: the ref, its workspace, and its marks (null while unknown). */
550export type DrawnHit = EverythingsThingRef & {
551 workspaceId: string | null;
552 workspaceName: string | null;
553 marks: EverythingsRowMark[] | null;
554};
555/** A search as the pane draws it: the hits it still knows, in the server's order, and the server's total. */
556export type DrawnSearch = { query: string; hits: DrawnHit[]; count: number; truncated: boolean };
557
558/** Looks like HTML the server will convert, so the stored Markdown is not knowable from the input. */
559export function looksLikeHtml(text: string): boolean {
560 return /<\/?(p|div|br|ul|ol|li|h[1-6]|table|tr|td|strong|em|b|i|a|span|blockquote|pre|code)\b[^>]*>/i.test(text);
561}
562
563/** Links in content that open another thing in the pane. */
564const THING_LINK = /https:\/\/(?:www\.)?everythings\.app\/workspaces\/[A-Za-z0-9-]+\/things\/([A-Za-z0-9-]+)/g;
565
566export function thingLinks(content: string): string[] {
567 const found = new Set<string>();
568 for (const match of content.matchAll(THING_LINK)) {
569 if (found.size >= 256) break;
570 found.add(match[0]);
571 }
572 return [...found];
573}
574
575export function thingIdFromLink(href: string): string | null {
576 const match = new RegExp(THING_LINK.source).exec(href);
577 return match ? (match[1] ?? null) : null;
578}
579
580/** One thing's page as the pane draws it: what is known, fitted to the text budget. Null is an absent section. */
581export type DrawnPage = {
582 id: string;
583 name: string;
584 emoji: string | null;
585 workspaceId: string | null;
586 workspaceName: string | null;
587 /** The thing it sits under, as the cache knows it; null at the top level or when unknown. */
588 parent: EverythingsThingRef | null;
589 content: string | null;
590 isContentCut: boolean;
591 links: string[];
592 agent: string | null;
593 request: EverythingsRequest | null;
594 /** Null while no read carried its marks. */
595 marks: EverythingsMark[] | null;
596 /** Its workspace's default marks; null while unknown. */
597 defaultMarks: EverythingsDefaultMark[] | null;
598 children: DrawnChildren | null;
599 comments: EverythingsComments | null;
600 /** Only the name and emoji are known: no content or section was recorded, or it was pruned. */
601 isNameOnly: boolean;
602 /** Its page was recorded once and pruned to keep the cache in bounds. */
603 isPruned: boolean;
604};
605
606/** The grid as the pane draws it. `isListed`: a full listing of the workspace was seen. */
607export type DrawnGrid = {
608 workspaces: EverythingsWorkspace[];
609 landing: {
610 workspaceId: string;
611 things: EverythingsThingRef[];
612 count: number;
613 truncated: boolean;
614 isListed: boolean;
615 } | null;
616};
617
618/** A sub-thing as its row draws it: the ref, and its marks (null while unknown). */
619export type DrawnChild = EverythingsThingRef & { marks: EverythingsRowMark[] | null };
620export type DrawnChildren = { things: DrawnChild[]; count: number; truncated: boolean };
621
622/** How many mark emojis a row shows before it says how many more there are. */
623export const ROW_MARKS_SHOWN = 5;
624
625/** The text a card or sub-thing row draws: the emoji (or NO_EMOJI), a separator, the name. */
626function refCost(ref: EverythingsThingRef): number {
627 return ref.name.length + (ref.emoji ?? NO_EMOJI).length + 1;
628}
629
630/** What a row's marks add to its label: up to ROW_MARKS_SHOWN emojis with spaces, "+n" and " | ". */
631function rowMarksCost(marks: EverythingsRowMark[] | null): number {
632 if (!marks || marks.length === 0) return 0;
633 return marks.slice(0, ROW_MARKS_SHOWN).reduce((sum, mark) => sum + mark.emoji.length + 1, 0) + 8;
634}
635
636/** Keeps the refs that fit in `budget.left`, in order, and spends it. */
637function fitRefs(refs: EverythingsThingRef[], budget: { left: number }): EverythingsThingRef[] {
638 const kept: EverythingsThingRef[] = [];
639 for (const ref of refs) {
640 if (refCost(ref) > budget.left) break;
641 budget.left -= refCost(ref);
642 kept.push(ref);
643 }
644 return kept;
645}
646
647/** The text drawRequest in pane.tsx draws for a question. */
648function requestCost(request: EverythingsRequest | null): number {
649 if (!request) return 0;
650 const options = request.options.reduce(
651 (sum, option) => sum + option.label.length + (option.description?.length ?? 0) + 4,
652 0,
653 );
654 const answer = request.answer;
655 const answered = answer
656 ? (answer.optionLabel?.length ?? 0) + (answer.comment?.length ?? 0) + (answer.userName?.length ?? 0) + 40
657 : 40;
658 return (request.createdBy?.length ?? 0) + 20 + request.question.length + options + answered;
659}
660
661/**
662 * A cached thing as a page to draw. The page's text is spent in the order
663 * the pane draws it, so what drops off first is the end of the page:
664 * comments, then sub-things, then links that would have opened in the pane
665 * (they open in the browser instead).
666 */
667export function fitPage(
668 thing: EverythingsCachedThing,
669 workspaceName: string | null,
670 parent: EverythingsThingRef | null,
671 defaultMarks: EverythingsDefaultMark[] | null,
672 rowMarksOf: (thingId: string) => EverythingsRowMark[] | undefined = () => undefined,
673): DrawnPage {
674 const content = thing.content ?? null;
675 const request = thing.request ?? null;
676 const marks = thing.marks ?? null;
677 const workspaceId = thing.workspaceId ?? null;
678 const budget = { left: PAGE_TEXT_BUDGET - PAGE_CHROME };
679 budget.left -= (workspaceName?.length ?? 0) + thing.name.length + (thing.emoji ?? NO_EMOJI).length + (thing.agent?.length ?? 0);
680 // The header's Button back to the parent; its label never grows past the name it clips.
681 budget.left -= parent ? refCost(parent) : 0;
682 // A mark Button draws a state glyph, its emoji and its count; a default mark's draws the glyph and
683 // the emoji. The line under the marks (a mark's name, at most 100, and a few words) is in PAGE_CHROME.
684 budget.left -= (marks ?? []).reduce((sum, mark) => sum + mark.emoji.length + 10, 0);
685 budget.left -= (defaultMarks ?? []).reduce((sum, mark) => sum + mark.emoji.length + 2, 0);
686 budget.left -= (workspaceId ? thingUrl(workspaceId, thing.id).length : 0) + (content?.length ?? 0) + requestCost(request);
687
688 const links: string[] = [];
689 for (const href of content !== null ? thingLinks(content) : []) {
690 if (href.length > budget.left) break;
691 budget.left -= href.length;
692 links.push(href);
693 }
694
695 let children: DrawnChildren | null = null;
696 if (thing.children) {
697 const kept: DrawnChild[] = [];
698 for (const ref of thing.children.things) {
699 const marks = rowMarksOf(ref.id) ?? null;
700 const cost = refCost(ref) + rowMarksCost(marks);
701 if (cost > budget.left) break;
702 budget.left -= cost;
703 kept.push({ ...ref, marks });
704 }
705 children = { things: kept, count: thing.children.count, truncated: thing.children.truncated || kept.length < thing.children.count };
706 }
707
708 let comments: EverythingsComments | null = null;
709 if (thing.comments) {
710 const kept: EverythingsComment[] = [];
711 for (const comment of thing.comments.list) {
712 // Drawn as the author, "via <agent>" and the text.
713 const cost = comment.authorName.length + (comment.byAgent ? comment.byAgent.length + 4 : 0) + comment.text.length;
714 if (cost > budget.left) break;
715 budget.left -= cost;
716 kept.push(comment);
717 }
718 comments = { list: kept, count: thing.comments.count, truncated: thing.comments.truncated || kept.length < thing.comments.count };
719 }
720
721 return {
722 id: thing.id,
723 name: thing.name,
724 emoji: thing.emoji,
725 workspaceId,
726 workspaceName,
727 parent,
728 content,
729 isContentCut: thing.isContentCut === true,
730 links,
731 agent: thing.agent ?? null,
732 request,
733 marks,
734 defaultMarks,
735 children,
736 comments,
737 isNameOnly: thing.pageSeen === 0,
738 isPruned: thing.isPruned === true,
739 };
740}
741
742/** A grid's workspaces and tiles, fitted to the text budget. */
743export function fitGrid(
744 workspaces: EverythingsWorkspace[],
745 landing: { workspaceId: string; things: EverythingsThingRef[]; count: number; isListed: boolean } | null,
746): DrawnGrid {
747 const budget = { left: PAGE_TEXT_BUDGET - PAGE_CHROME };
748 // A picker option draws its name, and its value may count as text too.
749 const named = workspaces.filter(ws => {
750 budget.left -= ws.name.length + ws.id.length;
751 return budget.left > 0;
752 });
753 if (!landing) return { workspaces: named, landing: null };
754 const things = fitRefs(landing.things, budget);
755 return {
756 workspaces: named,
757 landing: {
758 workspaceId: landing.workspaceId,
759 things,
760 count: Math.max(landing.count, landing.things.length),
761 truncated: things.length < Math.max(landing.count, landing.things.length),
762 isListed: landing.isListed,
763 },
764 };
765}
766hooks/follow.ts 67 lines1// Push follow: the pane shows what this session's agent writes, the moment
2// the write resolves. register.tsx hands every finished Everythings call
3// here from its `tool.call` hook, after `next(e)` resolved and before it
4// returns the result untouched. An Everythings call is told by its tool name
5// after the last `__`, whatever the server is called in this session.
6
7import type { ToolCallResult } from 'claude-code';
8
9import { recordCall } from './cache';
10import {
11 answerData,
12 FOLLOWED_WRITES,
13 GENERIC_TOOLS,
14 READ_ONLY_TOOLS,
15 isAnswered,
16 splitToolName,
17 UNIQUE_TOOLS,
18 writeTarget,
19} from './data';
20import { recordInboxCall, refreshInbox } from './inbox';
21import { agentCompletedView, followWrite, forgetFresh, refreshView } from './nav';
22import type { Ports } from './ports';
23import { isLearnedServer, learnServer } from './server';
24
25/**
26 * A call of a tool only Everythings has names its server, which the pane
27 * learns. A call of a tool name other servers use too (`search`,
28 * `add_comment`) counts only on the server already learned, so a Notion
29 * search never repoints the pane.
30 *
31 * Every Everythings call the agent makes goes into the session cache, so
32 * the pane can draw what it carried with no read of its own: a new thing
33 * from create_thing's input and result, new content from update_thing's
34 * result, a whole page from get_thing_view. Then a followed write points the
35 * pane at what it touched, and one read of the pane's own tries to complete
36 * the page, unless Claude Code refuses the pane's reads (blocked.ts). That
37 * read is left running, so the agent's call never waits on it, as are the
38 * band's reads of its counts (inbox.ts), which follow every such call.
39 */
40export async function observeCall(
41 p: Ports,
42 tool: string,
43 args: Record<string, unknown>,
44 ran: ToolCallResult,
45): Promise<void> {
46 const parts = splitToolName(tool);
47 if (!parts || !isAnswered(ran)) return;
48 if (UNIQUE_TOOLS.has(parts.name)) await learnServer(p, parts.server);
49 else if (!GENERIC_TOOLS.has(parts.name) || !(await isLearnedServer(p, parts.server))) return;
50
51 const data = answerData(ran);
52 const view = await p.view.read();
53 const pinned = view.kind === 'thing' ? view.thingId : null;
54 await p.cache.update(cache => recordCall(cache, parts.name, args, data, pinned));
55 await agentCompletedView(p, parts.name, args, data);
56 // The band's counts: what this call listed whole, then one read of each list it did not, left running.
57 const listed = await recordInboxCall(p, parts.name, args, data);
58 void refreshInbox(p, listed).catch(() => undefined);
59
60 // Any write may have changed pages the cache cannot patch: none counts as fresh now.
61 if (!READ_ONLY_TOOLS.has(parts.name)) await forgetFresh(p);
62 if (!FOLLOWED_WRITES.has(parts.name)) return;
63 const target = writeTarget(parts.name, args, data);
64 if (!target) return;
65 if (await followWrite(p, target)) void refreshView(p).catch(() => undefined);
66}
67hooks/inbox.ts 303 lines1// The band above the prompt, and waking the session when a question is answered.
2//
3// The band shows two counts: the open questions agents asked the person
4// (list_requests { status: 'open' }) and the open @Agent jobs people left in
5// comments (list_mentions). Each is a Button that lists its items in the
6// pane, where a press opens the thing; a count of zero, or one not known
7// yet, draws nothing, so with both at zero the band is the engine's. The
8// counts refresh at session start, after every Everythings call the session
9// makes, and on Refresh. An agent call that was itself the whole listing
10// (list_requests { status: 'open' } or list_mentions, over every workspace,
11// not cut at its limit) fills its count with no read; otherwise the band
12// makes one read of each list, on the learned server only (server.ts), never
13// probing for one. A refused read stops the band's reads until Refresh,
14// and blocks nothing else, as a refused search does; while the pane's own
15// reads are blocked (blocked.ts) the band reads nothing either.
16//
17// Wake on answer: when this session's own request_input resolves, the
18// question is remembered. While one is open, register.tsx runs one
19// repeating `$.clock.every` that calls list_requests { status: 'answered' }
20// every 5 minutes, for at most 2 hours after each ask. When an answer comes,
21// the session is woken with one prompt naming the thing by its id alone
22// (ask.ts's rule: an id that is not letters, digits and dashes sends
23// nothing), once per question. A refused poll stops polling for the
24// session; while the pane's reads are blocked a period reads nothing. The
25// poll never calls what_changed, which would move the agent's cursor.
26
27import type { EverythingsInbox, EverythingsInboxItem, EverythingsInboxList, EverythingsWait } from '../types';
28import { isBlocked } from './blocked';
29import {
30 answerData,
31 commentText,
32 INBOX_TOOLS,
33 isAnswered,
34 isRecord,
35 num,
36 readResult,
37 splitToolName,
38 str,
39} from './data';
40import type { Ports } from './ports';
41import { callLearnedServer, NotConnectedError, RefusedError } from './server';
42import type { ToolCallResult } from 'claude-code';
43
44/** How many rows the band asks for; the count says "100+" past it. */
45export const INBOX_LIMIT = 100;
46/** The server's default limit for both lists, when a call names none. */
47const SERVER_LIMIT = 50;
48/** How often the wake poll asks. */
49export const WAKE_POLL_MS = 5 * 60_000;
50/** How long after an ask the poll keeps asking. */
51export const WAKE_LIMIT_MS = 2 * 60 * 60_000;
52/** The poll's `since` reaches this far before the ask, for clocks that differ. */
53const SINCE_SLACK_MS = 10 * 60_000;
54const WOKE_KEPT = 100;
55const TEXT_MAX = 300;
56
57/** The ids the server mints: letters, digits and dashes. */
58const PLAIN_ID = /^[A-Za-z0-9-]{1,64}$/;
59
60const BOTH: readonly EverythingsInboxList[] = ['questions', 'jobs'];
61
62function rows(data: Record<string, unknown>, field: string): unknown[] {
63 return Array.isArray(data[field]) ? (data[field] as unknown[]) : [];
64}
65
66function oneLineText(text: string): string {
67 const flat = text.replace(/\s+/g, ' ').trim();
68 return flat.length > TEXT_MAX ? `${flat.slice(0, TEXT_MAX - 1)}…` : flat;
69}
70
71/** list_requests' rows as the band lists them. */
72export function parseQuestions(data: Record<string, unknown>): EverythingsInboxItem[] {
73 return rows(data, 'requests').flatMap(row => {
74 if (!isRecord(row)) return [];
75 const thingId = str(row.thingId, 100);
76 if (!thingId) return [];
77 const request = isRecord(row.request) ? row.request : {};
78 return [
79 {
80 key: thingId,
81 thingId,
82 name: str(row.name) ?? 'Untitled',
83 emoji: str(row.emoji, 32),
84 workspaceName: str(row.workspaceName),
85 text: oneLineText(str(request.question, 2_000) ?? ''),
86 },
87 ];
88 });
89}
90
91/** list_mentions' rows as the band lists them. */
92export function parseJobs(data: Record<string, unknown>): EverythingsInboxItem[] {
93 return rows(data, 'mentions').flatMap(row => {
94 if (!isRecord(row)) return [];
95 const thingId = str(row.thingId, 100);
96 const commentId = str(row.commentId, 100);
97 if (!thingId || !commentId) return [];
98 return [
99 {
100 key: commentId,
101 thingId,
102 name: str(row.thingName) ?? 'Untitled',
103 emoji: str(row.thingEmoji, 32),
104 workspaceName: str(row.workspaceName),
105 text: oneLineText(commentText(row.content)),
106 },
107 ];
108 });
109}
110
111const PARSE: Record<EverythingsInboxList, (data: Record<string, unknown>) => EverythingsInboxItem[]> = {
112 questions: parseQuestions,
113 jobs: parseJobs,
114};
115
116/** The band's own read of each list: every workspace, open only. */
117const BAND_ARGS: Record<EverythingsInboxList, Record<string, unknown>> = {
118 questions: { status: 'open', limit: INBOX_LIMIT },
119 jobs: { status: 'open', limit: INBOX_LIMIT },
120};
121
122/** The list an agent's call listed whole: every workspace, open only, not cut at its limit. */
123function wholeListing(
124 name: string,
125 args: Record<string, unknown>,
126 data: Record<string, unknown> | null,
127): { list: EverythingsInboxList; items: EverythingsInboxItem[] } | null {
128 if (data === null || args.workspaceId !== undefined || args.since !== undefined) return null;
129 let list: EverythingsInboxList;
130 if (name === INBOX_TOOLS.questions && args.status === 'open') list = 'questions';
131 else if (name === INBOX_TOOLS.jobs && (args.status === undefined || args.status === 'open')) list = 'jobs';
132 else return null;
133 const items = PARSE[list](data);
134 return items.length < num(args.limit, SERVER_LIMIT) ? { list, items } : null;
135}
136
137async function setList(p: Ports, list: EverythingsInboxList, items: EverythingsInboxItem[]): Promise<void> {
138 await p.inbox.update(inbox => ({ ...inbox, [list]: items }));
139}
140
141/**
142 * An answered Everythings call of the agent's (follow.ts): a whole listing
143 * of either list fills it. Answers the lists it filled, which the band then
144 * need not read.
145 */
146export async function recordInboxCall(
147 p: Ports,
148 name: string,
149 args: Record<string, unknown>,
150 data: Record<string, unknown> | null,
151): Promise<EverythingsInboxList[]> {
152 const whole = wholeListing(name, args, data);
153 if (whole === null) return [];
154 await setList(p, whole.list, whole.items);
155 return [whole.list];
156}
157
158/**
159 * Reads the lists not in `skip`, one call each on the learned server. Reads
160 * nothing while the pane's reads are blocked, or after a refusal of the
161 * band's own unless `isForced` (Refresh). A refusal stops the band's reads;
162 * a read that succeeds lets them go on. No server learned yet, or a failure:
163 * the count stands as it was.
164 */
165export async function refreshInbox(
166 p: Ports,
167 skip: readonly EverythingsInboxList[] = [],
168 isForced = false,
169): Promise<void> {
170 if (await isBlocked(p)) return;
171 if (!isForced && (await p.inbox.read()).isRefused) return;
172 for (const list of BOTH) {
173 if (skip.includes(list)) continue;
174 try {
175 const answer = readResult(await callLearnedServer(p, INBOX_TOOLS[list], BAND_ARGS[list]));
176 if ('error' in answer) continue;
177 const items = PARSE[list](answer.data);
178 await p.inbox.update(inbox => ({ ...inbox, [list]: items, isRefused: false }));
179 } catch (error) {
180 if (error instanceof RefusedError) {
181 await p.inbox.update(inbox => ({ ...inbox, isRefused: true }));
182 return;
183 }
184 if (error instanceof NotConnectedError) return;
185 // Another failure leaves the count as it stood.
186 }
187 }
188}
189
190/** The band's counts: null while unknown. */
191export function countsOf(inbox: EverythingsInbox): Record<EverythingsInboxList, number | null> {
192 return { questions: inbox.questions?.length ?? null, jobs: inbox.jobs?.length ?? null };
193}
194
195/** A count as the band draws it, "100+" when the read was cut at its limit. */
196export function countText(count: number): string {
197 return count >= INBOX_LIMIT ? `${INBOX_LIMIT}+` : String(count);
198}
199
200/** The prompt that wakes the session, naming the thing by its id; null for an id that is not a plain id. */
201export function wakeText(thingId: string): string | null {
202 if (!PLAIN_ID.test(thingId)) return null;
203 return `The person answered the question you asked on Everythings thing ${thingId}. Read the answer (list_requests { status: 'answered' }, or get_thing on that thing) and carry on.`;
204}
205
206/**
207 * The session's own request_input resolved: the question is waited on. A
208 * thing id that is not a plain id is never waited on, since its wake could
209 * not name it. Answers whether any question is waited on now.
210 */
211export async function watchAsk(p: Ports, tool: string, ran: ToolCallResult): Promise<boolean> {
212 const parts = splitToolName(tool);
213 if (parts === null || parts.name !== 'request_input' || !isAnswered(ran)) return false;
214 const data = answerData(ran);
215 const thingId = data !== null ? str(data.thingId, 100) : null;
216 if (thingId === null || !PLAIN_ID.test(thingId)) return false;
217 const request = data !== null && isRecord(data.request) ? data.request : {};
218 const round = typeof request.round === 'number' && Number.isFinite(request.round) ? request.round : null;
219 const askedAt = await p.now();
220 const key = `${thingId}:${round ?? askedAt}`;
221 const wake = await p.wake.update(last =>
222 last.isStopped
223 ? last
224 : {
225 ...last,
226 waits: [...last.waits.filter(wait => wait.thingId !== thingId), { thingId, key, round, askedAt }],
227 },
228 );
229 return wake.waits.length > 0;
230}
231
232/** True while a question is waited on and polling may go on. */
233export async function isWaiting(p: Ports): Promise<boolean> {
234 const wake = await p.wake.read();
235 return !wake.isStopped && wake.waits.length > 0;
236}
237
238/** Whether an answered row answers the wait: its thing, and its round or a later one. */
239function answers(row: unknown, wait: EverythingsWait): boolean {
240 if (!isRecord(row) || row.thingId !== wait.thingId) return false;
241 const request = isRecord(row.request) ? row.request : {};
242 if (request.status !== undefined && request.status !== 'answered') return false;
243 return wait.round === null || typeof request.round !== 'number' || request.round >= wait.round;
244}
245
246/**
247 * One period of the wake poll. Waits older than two hours go; then one
248 * list_requests { status: 'answered' } call, since a little before the
249 * oldest ask. Each wait it answers wakes the session once. Answers whether
250 * the poll should go on.
251 */
252export async function pollAnswers(p: Ports): Promise<boolean> {
253 const now = await p.now();
254 const wake = await p.wake.update(last => {
255 const waits = last.waits.filter(wait => now - wait.askedAt <= WAKE_LIMIT_MS);
256 return waits.length === last.waits.length ? last : { ...last, waits };
257 });
258 if (wake.isStopped || wake.waits.length === 0) return false;
259 if (await isBlocked(p)) return true;
260
261 const oldest = Math.min(...wake.waits.map(wait => wait.askedAt));
262 let found: unknown[];
263 try {
264 const answer = readResult(
265 await callLearnedServer(p, INBOX_TOOLS.questions, {
266 status: 'answered',
267 since: new Date(Math.max(0, oldest - SINCE_SLACK_MS)).toISOString(),
268 limit: INBOX_LIMIT,
269 }),
270 );
271 if ('error' in answer) return true;
272 found = rows(answer.data, 'requests');
273 } catch (error) {
274 if (error instanceof RefusedError) {
275 await p.wake.update(last => ({ ...last, waits: [], isStopped: true }));
276 return false;
277 }
278 // No server reached, or a failure: the next period asks again.
279 return true;
280 }
281
282 const answered = wake.waits.filter(wait => found.some(row => answers(row, wait)));
283 if (answered.length === 0) return true;
284 // Worked out before the write: a change may run more than once (ports.ts).
285 const woke = (await p.wake.read()).woke;
286 const toWake = answered.filter(wait => !woke.includes(wait.key));
287 const after = await p.wake.update(last => ({
288 ...last,
289 waits: last.waits.filter(wait => !answered.some(done => done.key === wait.key)),
290 woke: [...last.woke, ...toWake.map(wait => wait.key).filter(key => !last.woke.includes(key))].slice(-WOKE_KEPT),
291 }));
292 for (const wait of toWake) {
293 const text = wakeText(wait.thingId);
294 if (text === null) continue;
295 try {
296 await p.wakeSession(text);
297 } catch {
298 // The question still counts as woken: it never wakes the session twice.
299 }
300 }
301 return !after.isStopped && after.waits.length > 0;
302}
303hooks/nav.ts 580 lines1// Navigation and loading: what every screen change does to the state.
2//
3// The pane draws from the session cache (cache.ts), so a screen shows what
4// is known at once. Then the pane makes ONE read of its own to complete it:
5// get_thing_view for a thing, get_workspace_view for a grid (a second call
6// only when the default workspace changed since the last visit). When that
7// read fails, the cached screen stays and the status says why; nothing
8// retries on its own. When Claude Code refuses it, the pane is blocked
9// (blocked.ts): from then on navigation reads nothing, and only Refresh
10// tries again. A press on a page a read filled whole less than 30 seconds
11// ago reads nothing either. Nothing here runs on a timer: a read happens
12// when someone navigates, presses Refresh or Retry, or a followed write
13// resolves. Reads may overlap and finish in any order, so each takes a
14// number from `load.seq` and records its answer only while it is the latest.
15//
16// Every write to a value the pane draws from redraws it, and a redraw that
17// comes between a click's focus move and its press drops the press. So a
18// read shows "Loading…" only when the person pressed Refresh or Retry, and
19// its answer is written only when it changes what the pane draws.
20//
21// `/findthing` shows a search: one search_things call on the learned server,
22// its hits a list. It never reads while blocked, and a search the pane could
23// not run is one register.tsx asks Claude to run (unfilledPage); the agent's
24// answer fills the list through follow (agentCompletedView).
25
26import type {
27 EverythingsBlocked,
28 EverythingsCache,
29 EverythingsDefaultMark,
30 EverythingsInbox,
31 EverythingsInboxList,
32 EverythingsLoad,
33 EverythingsView,
34 EverythingsWrites,
35} from '../types';
36import { askKey, leaveAsk, type AskTarget } from './ask';
37import { block, isBlocked, showNote, unblock } from './blocked';
38import {
39 aliasSearch,
40 drawnOf,
41 gridOf,
42 isWholePage,
43 pageOf,
44 recordDefaultMarks,
45 recordGrid,
46 recordPage,
47 recordSearch,
48 searchOf,
49} from './cache';
50import {
51 cleanQuery,
52 IDLE_LOAD,
53 isRecord,
54 parseDefaultMarks,
55 parseGridView,
56 parseThingView,
57 READ_TOOLS,
58 readResult,
59 searchKey,
60 viewKey,
61 type WriteTarget,
62} from './data';
63import { refreshInbox } from './inbox';
64import type { Ports } from './ports';
65import { callEverythings, callLearnedServer, NotConnectedError, RefusedError } from './server';
66import { isLive, leaveWrites } from './writes';
67
68const LAST_WORKSPACE = 'lastWorkspaceId';
69const DEFAULT_WORKSPACE = 'defaultWorkspaceId';
70const UNREADABLE = 'The Everythings server sent a reply the pane cannot read.';
71/** Why the pane's own search did not run; it asks Claude to run it instead. */
72export const SEARCH_REFUSED = "Claude Code's permission mode blocked the pane's search.";
73export const SEARCH_UNREACHED = 'The pane has not reached Everythings yet in this session.';
74/** A page a read filled this recently is shown from the cache on a press, with no read. */
75export const FRESH_MS = 30_000;
76
77/** Notes that a read filled the view `key` names, now. Stamps older than FRESH_MS go. */
78async function stampFresh(p: Ports, key: string): Promise<void> {
79 const now = await p.now();
80 await p.fresh.update(fresh => {
81 const kept = Object.entries(fresh).filter(([one, at]) => one !== key && now - at < FRESH_MS);
82 return Object.fromEntries([...kept, [key, now]]);
83 });
84}
85
86/** A write in this session may have changed any page: none counts as fresh. */
87export async function forgetFresh(p: Ports): Promise<void> {
88 await p.fresh.update(fresh => (Object.keys(fresh).length === 0 ? fresh : {}));
89}
90
91/** True when the cache holds the whole view and a read filled it less than FRESH_MS ago. */
92async function isFresh(p: Ports, view: EverythingsView): Promise<boolean> {
93 if (view.kind === 'search' || view.kind === 'inbox') return false;
94 const cache = await p.cache.read();
95 const isWhole =
96 view.kind === 'thing'
97 ? isWholePage(cache, view.thingId)
98 : view.workspaceId !== null && cache.order[view.workspaceId] !== undefined;
99 if (!isWhole) return false;
100 const at = (await p.fresh.read())[viewKey(view)];
101 return at !== undefined && (await p.now()) - at < FRESH_MS;
102}
103
104/**
105 * Writes what a read brought into the cache, unless it changes nothing that
106 * `views` draw: then the pane is not redrawn for it.
107 */
108async function recordRead(
109 p: Ports,
110 views: EverythingsView[],
111 record: (cache: EverythingsCache) => EverythingsCache,
112): Promise<void> {
113 await p.cache.update(cache => {
114 const next = record(cache);
115 return views.every(view => drawnOf(cache, view) === drawnOf(next, view)) ? cache : next;
116 });
117}
118
119/** Everything a drawing needs, read in one go (and subscribed to). */
120export type PaneSnapshot = {
121 view: EverythingsView;
122 cache: EverythingsCache;
123 /** The latest read when it was for the view on screen; idle otherwise. */
124 load: EverythingsLoad;
125 isFollowing: boolean;
126 /** Set while Claude Code refuses the pane's reads. */
127 blocked: EverythingsBlocked | null;
128 isNoteDismissed: boolean;
129 /** The page the pane asked Claude to open, while that ask is pending. */
130 asked: string | null;
131 /** The person's own marks and comments in flight, and how the last one went. */
132 writes: EverythingsWrites;
133 /** The band's lists, which the pane lists on an inbox screen. */
134 inbox: EverythingsInbox;
135};
136
137export async function readSnapshot(p: Ports): Promise<PaneSnapshot> {
138 const view = await p.view.read();
139 const cache = await p.cache.read();
140 const load = await p.load.read();
141 const isFollowing = await p.follow.read();
142 const blocked = await p.blocked.read();
143 const isNoteDismissed = await p.noteDismissed.read();
144 const asked = await p.asked.read();
145 const stored = await p.writes.read();
146 const inbox = await p.inbox.read();
147 // Markers an earlier load left (older than 30 seconds) draw as nothing in flight.
148 const now = await p.now();
149 const isStale = (at: number) => !isLive(at, now);
150 const writes: EverythingsWrites = {
151 ...stored,
152 pending: Object.fromEntries(Object.entries(stored.pending).filter(([, at]) => !isStale(at))),
153 commenting: Object.fromEntries(Object.entries(stored.commenting).filter(([, at]) => !isStale(at))),
154 last:
155 stored.last !== null && (stored.last.did === 'adding' || stored.last.did === 'removing') && isStale(stored.last.at)
156 ? null
157 : stored.last,
158 };
159 return {
160 view,
161 cache,
162 load: load.key === viewKey(view) ? load : IDLE_LOAD,
163 isFollowing,
164 blocked,
165 isNoteDismissed,
166 asked,
167 writes,
168 inbox,
169 };
170}
171
172type Outcome<T> =
173 | { ok: true; value: T }
174 | { ok: false; error: string | null; isNotConnected: boolean; refused: RefusedError | null };
175
176function failed(error: string | null, isNotConnected = false): Outcome<never> {
177 return { ok: false, error, isNotConnected, refused: null };
178}
179
180async function fetchView<T>(
181 p: Ports,
182 tool: string,
183 args: Record<string, unknown>,
184 shape: (data: unknown) => T | null,
185): Promise<Outcome<T>> {
186 try {
187 const answer = readResult(await callEverythings(p, tool, args));
188 if ('error' in answer) return failed(answer.error);
189 const value = shape(answer.data);
190 return value ? { ok: true, value } : failed(UNREADABLE);
191 } catch (error) {
192 if (error instanceof NotConnectedError) return failed(null, true);
193 // A refusal is no failure to show: the pane is blocked instead (endLoad).
194 if (error instanceof RefusedError) return { ok: false, error: null, isNotConnected: false, refused: error };
195 return failed(error instanceof Error ? error.message : String(error));
196 }
197}
198
199/**
200 * Starts a read for the view `key` names; answers the read's number.
201 * `isShown`: the read shows as "Loading…" (Refresh and Retry).
202 */
203async function startLoad(p: Ports, key: string, isShown: boolean): Promise<number> {
204 const load = await p.load.update(last => ({
205 seq: last.seq + 1,
206 key,
207 isLoading: isShown,
208 error: null,
209 isNotConnected: false,
210 }));
211 return load.seq;
212}
213
214/** False once a later read has started: this one's answer is stale. */
215async function isLatest(p: Ports, seq: number): Promise<boolean> {
216 return (await p.load.read()).seq === seq;
217}
218
219function isRefused(outcome: Outcome<unknown>): boolean {
220 return !outcome.ok && outcome.refused !== null;
221}
222
223/** Records how a read ended. A success ends blocked mode; a refusal starts it, or keeps it quietly. */
224async function endLoad(p: Ports, seq: number, outcome: Outcome<unknown>): Promise<void> {
225 if (outcome.ok) await unblock(p);
226 else if (outcome.refused !== null) await block(p, outcome.refused);
227 await p.load.update(load =>
228 load.seq !== seq
229 ? load
230 : outcome.ok
231 ? { ...load, isLoading: false, error: null, isNotConnected: false }
232 : { ...load, isLoading: false, error: outcome.error, isNotConnected: outcome.isNotConnected },
233 );
234}
235
236async function storedId(p: Ports, key: string): Promise<string | null> {
237 const value = await p.storeGet(key);
238 return typeof value === 'string' && value ? value : null;
239}
240
241/**
242 * Reads the grid of `workspaceId`. With none named the pane lands on the
243 * default workspace, else the last one opened, else the first. It asks for
244 * the landing those rules gave last time, so a steady account costs one
245 * read; a second read happens only when the default changed meanwhile.
246 */
247async function loadGrid(p: Ports, workspaceId: string | null, isShown: boolean): Promise<boolean> {
248 const seq = await startLoad(p, viewKey({ kind: 'grid', workspaceId }), isShown);
249 const since = (await p.cache.read()).tick;
250
251 const last = await storedId(p, LAST_WORKSPACE);
252 const preferred = workspaceId ?? (await storedId(p, DEFAULT_WORKSPACE)) ?? last;
253 let outcome = await fetchView(p, READ_TOOLS.grid, preferred ? { workspaceId: preferred } : {}, parseGridView);
254
255 if (outcome.ok && workspaceId === null && outcome.value.landing) {
256 const grid = outcome.value;
257 const lastIsLive = last !== null && grid.workspaces.some(ws => ws.id === last);
258 const wanted = grid.defaultWorkspaceId ?? (lastIsLive ? last : null) ?? grid.workspaces[0]?.id ?? null;
259 if (wanted && wanted !== grid.landing?.workspaceId) {
260 const corrected = await fetchView(p, READ_TOOLS.grid, { workspaceId: wanted }, parseGridView);
261 if (corrected.ok) outcome = corrected;
262 }
263 }
264
265 if (outcome.ok && (await isLatest(p, seq))) {
266 const grid = outcome.value;
267 const landed = grid.landing?.workspaceId ?? null;
268 const views: EverythingsView[] = [
269 { kind: 'grid', workspaceId },
270 { kind: 'grid', workspaceId: landed },
271 ];
272 await recordRead(p, views, cache => recordGrid(cache, grid, since, null));
273 await p.view.update(view =>
274 view.kind === 'grid' && view.workspaceId === workspaceId ? { kind: 'grid', workspaceId: landed } : view,
275 );
276 if (landed) await stampFresh(p, viewKey({ kind: 'grid', workspaceId: landed }));
277 await p.storeSet(DEFAULT_WORKSPACE, grid.defaultWorkspaceId);
278 if (landed) await p.storeSet(LAST_WORKSPACE, landed);
279 }
280 await endLoad(p, seq, outcome);
281 return isRefused(outcome);
282}
283
284/**
285 * The workspace's default marks, read once per workspace per session when a
286 * thing page opens and they are unknown. A failure or a refusal leaves them
287 * unknown: the page then offers only the marks the thing carries. A refusal
288 * of this read alone blocks nothing, so a person who allowed only the page
289 * reads keeps them.
290 */
291async function fetchDefaults(p: Ports, workspaceId: string | null): Promise<DefaultMarks | null> {
292 if (workspaceId === null || (await p.cache.read()).defaultMarks[workspaceId] !== undefined) return null;
293 let isFirst = false;
294 await p.defaultsAsked.update(asked => {
295 isFirst = !asked.includes(workspaceId);
296 return isFirst ? [...asked, workspaceId].slice(-50) : asked;
297 });
298 if (!isFirst) return null;
299 const outcome = await fetchView(p, READ_TOOLS.defaults, { workspaceId }, parseDefaultMarks);
300 return outcome.ok ? { workspaceId, marks: outcome.value } : null;
301}
302
303type DefaultMarks = { workspaceId: string; marks: EverythingsDefaultMark[] };
304
305async function loadThing(p: Ports, thingId: string, isShown: boolean): Promise<boolean> {
306 const seq = await startLoad(p, viewKey({ kind: 'thing', thingId, workspaceId: null }), isShown);
307 const since = (await p.cache.read()).tick;
308 // The defaults first, so the page and its marks go into the cache in one write.
309 const defaults = await fetchDefaults(p, (await p.cache.read()).things[thingId]?.workspaceId ?? null);
310 const outcome = await fetchView(p, READ_TOOLS.thing, { thingId }, parseThingView);
311 if (defaults !== null && !(outcome.ok && (await isLatest(p, seq)))) {
312 await p.cache.update(cache => recordDefaultMarks(cache, defaults.workspaceId, defaults.marks));
313 }
314 if (outcome.ok && (await isLatest(p, seq))) {
315 const page = outcome.value;
316 await recordRead(p, [{ kind: 'thing', thingId, workspaceId: null }], cache => {
317 const read = recordPage(cache, page, since, thingId);
318 return defaults === null ? read : recordDefaultMarks(read, defaults.workspaceId, defaults.marks);
319 });
320 const workspaceId = page.workspace?.id ?? page.thing.workspaceId ?? null;
321 if (workspaceId) {
322 await p.view.update(view =>
323 view.kind === 'thing' && view.thingId === thingId ? { ...view, workspaceId } : view,
324 );
325 }
326 await stampFresh(p, viewKey({ kind: 'thing', thingId, workspaceId: null }));
327 }
328 await endLoad(p, seq, outcome);
329 return isRefused(outcome);
330}
331
332/**
333 * One search_things call on the learned server, no other name tried. A
334 * refusal blocks nothing (the page reads may be allowed when the search is
335 * not); it fails the search, and the pane asks Claude to run it.
336 */
337async function loadSearch(p: Ports, query: string, isShown: boolean): Promise<void> {
338 const view: EverythingsView = { kind: 'search', query, workspaceId: null };
339 const seq = await startLoad(p, viewKey(view), isShown);
340 const since = (await p.cache.read()).tick;
341 let outcome: Outcome<Record<string, unknown>>;
342 try {
343 const answer = readResult(await callLearnedServer(p, READ_TOOLS.search, { query }));
344 outcome = 'error' in answer ? failed(answer.error) : { ok: true, value: answer.data };
345 } catch (error) {
346 if (error instanceof NotConnectedError) outcome = failed(SEARCH_UNREACHED);
347 else if (error instanceof RefusedError) outcome = failed(SEARCH_REFUSED);
348 else outcome = failed(error instanceof Error ? error.message : String(error));
349 }
350 if (outcome.ok && (await isLatest(p, seq))) {
351 const data = outcome.value;
352 await recordRead(p, [view], cache => recordSearch(cache, query, data, since, null));
353 }
354 await p.load.update(load =>
355 load.seq !== seq
356 ? load
357 : { ...load, isLoading: false, error: outcome.ok ? null : outcome.error, isNotConnected: false },
358 );
359}
360
361/** The workspace a thing view starts under, before its read says. */
362async function workspaceOf(p: Ports, thingId: string): Promise<string | null> {
363 const known = (await p.cache.read()).things[thingId]?.workspaceId;
364 return known ?? (await p.view.read()).workspaceId;
365}
366
367/**
368 * Puts `view` on screen. An ask pending for another page goes. While the
369 * pane is blocked the cache alone draws it, and a failure an earlier read
370 * left for it goes too, so no error line or Retry shows.
371 */
372async function show(p: Ports, view: EverythingsView): Promise<void> {
373 await p.view.update(() => view);
374 await leaveAsk(p);
375 await leaveWrites(p);
376 if (await isBlocked(p)) {
377 const key = viewKey(view);
378 await p.load.update(load =>
379 load.key === key && (load.error !== null || load.isNotConnected)
380 ? { ...load, error: null, isNotConnected: false }
381 : load,
382 );
383 }
384}
385
386/**
387 * Shows the grid of `workspaceId` (null: the landing rules) and reads it,
388 * unless blocked, or unless `mayReuse` and a read filled it moments ago.
389 */
390export async function openGrid(p: Ports, workspaceId: string | null, mayReuse = false): Promise<void> {
391 const view: EverythingsView = { kind: 'grid', workspaceId };
392 await show(p, view);
393 if (await isBlocked(p)) return;
394 if (mayReuse && (await isFresh(p, view))) return;
395 await loadGrid(p, workspaceId, false);
396}
397
398/**
399 * Shows one thing (what the cache knows of it at once) and reads it, unless
400 * blocked, or unless `mayReuse` and a read filled it moments ago.
401 */
402export async function openThing(p: Ports, thingId: string, mayReuse = false): Promise<void> {
403 const workspaceId = await workspaceOf(p, thingId);
404 const view: EverythingsView = { kind: 'thing', thingId, workspaceId };
405 await show(p, view);
406 if (await isBlocked(p)) return;
407 if (mayReuse && (await isFresh(p, view))) return;
408 await loadThing(p, thingId, false);
409}
410
411/**
412 * Reads the view on screen; what it shows stays drawn meanwhile. `isShown`:
413 * the read shows as "Loading…". Answers whether Claude Code refused it.
414 */
415async function readView(p: Ports, isShown: boolean): Promise<boolean> {
416 const view = await p.view.read();
417 if (view.kind === 'search') {
418 if (view.query) await loadSearch(p, view.query, isShown);
419 return false;
420 }
421 if (view.kind === 'inbox') {
422 await refreshInbox(p, [], isShown);
423 return false;
424 }
425 return view.kind === 'grid' ? loadGrid(p, view.workspaceId, isShown) : loadThing(p, view.thingId, isShown);
426}
427
428/** Reads the view on screen again, unless blocked (/things and follow). */
429export async function refreshView(p: Ports): Promise<void> {
430 if (!(await isBlocked(p))) await readView(p, false);
431}
432
433/**
434 * The page to ask Claude for when the pane will not fill the screen by
435 * itself (no server answered, Claude Code refuses its reads, or the read
436 * failed) and the cache has nothing to draw there. Null when the screen
437 * draws something, or a read may still fill it.
438 */
439export async function unfilledPage(p: Ports): Promise<AskTarget | null> {
440 const { view, cache, load, blocked } = await readSnapshot(p);
441 if (view.kind === 'inbox') return null;
442 if (blocked === null && load.error === null && !load.isNotConnected) return null;
443 if (view.kind === 'search') {
444 return view.query && searchOf(cache, view.query) === null ? { kind: 'search', query: view.query } : null;
445 }
446 if (view.kind === 'thing') {
447 return pageOf(cache, view.thingId) === null ? { kind: 'thing', id: view.thingId } : null;
448 }
449 const landing = gridOf(cache, view.workspaceId).landing;
450 if (landing !== null && (landing.things.length > 0 || landing.isListed)) return null;
451 return { kind: 'workspace', id: landing?.workspaceId ?? null };
452}
453
454/**
455 * Refresh and Retry: one read, blocked or not, however fresh the page. The
456 * one way out of blocked mode. The person asked for this read, so a refusal
457 * shows the note again, even after Dismiss, to say why nothing changed.
458 * Then the band's counts are read again, even after a refusal of their own
459 * (an inbox screen's read is that one).
460 */
461export async function pressRefresh(p: Ports): Promise<void> {
462 if (await readView(p, true)) await showNote(p);
463 if ((await p.view.read()).kind !== 'inbox') await refreshInbox(p, [], true);
464}
465
466/**
467 * A press of a count on the band: the pane opens on that list, from what
468 * the band holds, and reads it again. It pauses follow, as a press on a
469 * thing does; a press on a row opens that thing.
470 */
471export async function pressInbox(p: Ports, list: EverythingsInboxList): Promise<void> {
472 await p.openPane();
473 await p.follow.update(() => false);
474 const { workspaceId } = await p.view.read();
475 await show(p, { kind: 'inbox', list, workspaceId });
476 await refreshInbox(p);
477}
478
479/**
480 * The person's presses on a thing, a workspace or back: each pauses follow,
481 * so the next write leaves the pane be, and a page a read filled moments ago
482 * shows with no read. Refresh and Retry leave follow as it is.
483 */
484export async function pressThing(p: Ports, thingId: string): Promise<void> {
485 await p.follow.update(() => false);
486 await openThing(p, thingId, true);
487}
488
489export async function pressWorkspace(p: Ports, workspaceId: string): Promise<void> {
490 await p.follow.update(() => false);
491 await openGrid(p, workspaceId, true);
492}
493
494/**
495 * `/findthing <query>`: the search on screen as a list, from one
496 * search_things call, unless blocked. It pauses follow, as a press on a
497 * thing does. With no query the screen says to type one, and nothing is read.
498 */
499export async function runSearch(p: Ports, typed: string): Promise<void> {
500 await p.follow.update(() => false);
501 const query = cleanQuery(typed);
502 const { workspaceId } = await p.view.read();
503 await show(p, { kind: 'search', query, workspaceId });
504 if (!query || (await isBlocked(p))) return;
505 await loadSearch(p, query, true);
506}
507
508/** From a thing or a search back to its workspace's grid. */
509export async function pressBack(p: Ports): Promise<void> {
510 await p.follow.update(() => false);
511 await openGrid(p, (await p.view.read()).workspaceId, true);
512}
513
514export async function toggleFollow(p: Ports): Promise<void> {
515 await p.follow.update(isFollowing => !isFollowing);
516}
517
518/** Opening the pane arms follow again. */
519export async function resumeFollow(p: Ports): Promise<void> {
520 await p.follow.update(() => true);
521}
522
523/**
524 * Points the pane at what a write touched, when follow is on: the thing it
525 * wrote. A delete of the thing on screen goes back to its grid; a delete of
526 * anything else leaves the view where it is, to be read again (a deleted
527 * sub-thing leaves its parent's list). Answers whether a read is worth making
528 * now: only while the pane is open (a closed one reads when it opens).
529 */
530export async function followWrite(p: Ports, target: WriteTarget): Promise<boolean> {
531 if (!(await p.follow.read())) return false;
532 const view = await p.view.read();
533 if (target.kind === 'deleted') {
534 await p.view.update(shown =>
535 shown.kind === 'thing' && shown.thingId === target.thingId
536 ? { kind: 'grid', workspaceId: shown.workspaceId }
537 : shown,
538 );
539 await leaveAsk(p);
540 } else if (view.kind !== 'thing' || view.thingId !== target.thingId) {
541 const workspaceId = await workspaceOf(p, target.thingId);
542 await show(p, { kind: 'thing', thingId: target.thingId, workspaceId });
543 }
544 return p.isPaneOpen();
545}
546
547/**
548 * The agent read the page on screen itself (get_thing_view, or
549 * get_workspace_view of the grid on screen): the screen is complete, so a
550 * failure line left by the pane's own read goes.
551 */
552export async function agentCompletedView(
553 p: Ports,
554 name: string,
555 args: Record<string, unknown>,
556 data: Record<string, unknown> | null,
557): Promise<void> {
558 const view = await p.view.read();
559 let isComplete = false;
560 if (name === READ_TOOLS.search && view.kind === 'search' && typeof args.query === 'string') {
561 // The agent ran the search on screen, maybe in its own words when the pane asked it to.
562 isComplete = searchKey(args.query) === searchKey(view.query);
563 const cache = await p.cache.read();
564 if (!isComplete && (await p.asked.read()) === askKey(view, cache)) {
565 const query = args.query;
566 await p.cache.update(now => aliasSearch(now, query, view.query));
567 isComplete = true;
568 }
569 } else if (name === READ_TOOLS.thing) {
570 isComplete = view.kind === 'thing' && args.thingId === view.thingId;
571 } else if (name === READ_TOOLS.grid && data && isRecord(data.landing)) {
572 isComplete = view.kind === 'grid' && (view.workspaceId === null || data.landing.workspaceId === view.workspaceId);
573 }
574 if (!isComplete) return;
575 const key = viewKey(view);
576 await p.load.update(load =>
577 load.key === key && !load.isLoading ? { ...load, error: null, isNotConnected: false } : load,
578 );
579}
580hooks/pane.tsx 813 lines1// The pane's drawing: whichever screen the state names, from the session
2// cache, as a tree of the surface's elements. It reads, and it writes two
3// things, each only on the person's own action: their mark on a press of a
4// mark Button, their comment on a submit of the comment field (writes.ts).
5// Its other Buttons navigate, follow, refresh and retry, copy permission
6// rules, dismiss a note, and ask Claude to open a page. An agent's question
7// shows with its options and answer; the person answers it in the app. A
8// section nobody has read yet is absent. A search (`/findthing`) draws its
9// hits as a list of rows, each naming its workspace. An inbox screen (a
10// press of a count on the band above the prompt) lists the open questions
11// or the open @Agent jobs as rows, a press opening the thing. drawBand draws
12// that band.
13//
14// register.tsx's `ui.render` hook calls drawPane with the surface's element
15// table, a snapshot of the state, and the actions its presses run.
16
17import type { ElementTable, RenderElement, RenderSurface } from 'claude-code';
18
19import type {
20 EverythingsBlocked,
21 EverythingsDefaultMark,
22 EverythingsInbox,
23 EverythingsInboxList,
24 EverythingsRequest,
25 EverythingsThingRef,
26 EverythingsWrites,
27} from '../types';
28import { askClaude, askKey, type AskTarget } from './ask';
29import { copyRules, dismissNote } from './blocked';
30import { gridOf, pageOf, searchOf } from './cache';
31import { countsOf, countText } from './inbox';
32import {
33 clip,
34 NO_EMOJI,
35 ROW_MARKS_SHOWN,
36 thingIdFromLink,
37 thingUrl,
38 type DrawnChild,
39 type DrawnGrid,
40 type DrawnHit,
41 type DrawnPage,
42 type DrawnSearch,
43} from './data';
44import {
45 pressBack,
46 pressRefresh,
47 pressThing,
48 pressWorkspace,
49 toggleFollow,
50 type PaneSnapshot,
51} from './nav';
52import type { Ports } from './ports';
53import { dismissWriteNote, pressMark, submitComment, type MarkPress } from './writes';
54
55/** What the pane's presses do. A press on a thing, a workspace or back pauses follow. */
56export type PaneActions = {
57 openThing: (thingId: string) => void;
58 openWorkspace: (workspaceId: string) => void;
59 back: () => void;
60 toggleFollow: () => void;
61 /** Refresh and Retry: one read, also while Claude Code refuses the pane's reads. */
62 refresh: () => void;
63 ask: (target: AskTarget) => void;
64 copyRules: (on: 'reads' | 'writes', surface: RenderSurface) => void;
65 dismissNote: () => void;
66 dismissWriteNote: () => void;
67 mark: (thingId: string, mark: MarkPress) => void;
68 comment: (thingId: string, text: string) => void;
69};
70
71/** A press handler returns at once; its work runs on and never throws. */
72function run(work: Promise<void>): void {
73 void work.catch(() => undefined);
74}
75
76export function paneActions(p: Ports): PaneActions {
77 return {
78 openThing: thingId => run(pressThing(p, thingId)),
79 openWorkspace: workspaceId => run(pressWorkspace(p, workspaceId)),
80 back: () => run(pressBack(p)),
81 toggleFollow: () => run(toggleFollow(p)),
82 refresh: () => run(pressRefresh(p)),
83 ask: target => run(askClaude(p, target)),
84 copyRules: (on, surface) => run(copyRules(p, on, surface)),
85 dismissNote: () => run(dismissNote(p)),
86 dismissWriteNote: () => run(dismissWriteNote(p)),
87 mark: (thingId, mark) => run(pressMark(p, thingId, mark)),
88 comment: (thingId, text) => run(submitComment(p, thingId, text)),
89 };
90}
91
92export const NOT_CONNECTED = 'The Everythings connector is not connected.';
93/** Said beside a failed read when the screen holds what the session's calls carried. */
94export const FROM_CALLS = "Showing what this session's calls carried.";
95export const NOTHING_SEEN = 'Nothing from Everythings has been seen yet in this session.';
96export const THING_NOT_SEEN = 'This thing has not been seen yet in this session.';
97/** The note shown while Claude Code refuses the pane's reads. */
98export const BLOCKED_NOTE =
99 "Claude Code's permission mode blocks the pane's own reads, so the pane shows what Claude has read in this chat.";
100export const UNBLOCK_HOW =
101 "To let it read, allow these read-only tools in Claude Code's permission settings, then press Refresh:";
102/** The note for a refused write; the line saying it was blocked sits where the person pressed. */
103export const WRITE_UNBLOCK_HOW: Record<'mark' | 'comment', string> = {
104 mark: "To allow marks from the pane, add these to Claude Code's permission settings:",
105 comment: "To allow comments from the pane, add this to Claude Code's permission settings:",
106};
107export const THING_UNREAD = 'Claude has not read this thing in this chat yet.';
108export const THING_PRUNED = 'The pane kept only the name of this thing, to save room.';
109export const THING_ASKED = 'Asked Claude to open it. The page fills in once Claude reads it.';
110export const WORKSPACE_UNLISTED = 'Claude has not listed this workspace in this chat yet.';
111export const WORKSPACE_ASKED = 'Asked Claude to list it. The grid fills in once Claude lists it.';
112export const WORKSPACE_ASKED_FIRST = 'Asked Claude to show your workspace. The grid fills in once Claude reads it.';
113export const NO_QUERY = 'Type what to find after the command: /findthing <query>';
114export const SEARCH_UNRUN = 'Claude has not run this search in this chat yet.';
115export const SEARCH_ASKED = 'Asked Claude to search. The list fills in once Claude searches.';
116export const SEARCHING = 'Searching…';
117export const NO_HITS = 'No things match.';
118/** An inbox screen's title, and what it says with nothing to list. */
119export const INBOX_TITLE: Record<EverythingsInboxList, string> = { questions: 'Open questions', jobs: 'Open @Agent jobs' };
120export const INBOX_EMPTY: Record<EverythingsInboxList, string> = {
121 questions: 'No open questions.',
122 jobs: 'No open @Agent jobs.',
123};
124export const INBOX_REFUSED = "Claude Code's permission mode blocked the pane's read of this list.";
125export const INBOX_UNREAD = 'This list has not been read yet in this session.';
126
127/** The Button back to the workspace's grid: a grid of nine dots. */
128export const GRID_GLYPH = '⋮⋮⋮';
129const CHEVRON = '›';
130/** A card's width in cells, border and padding included; the name gets the rest. */
131const CARD_WIDTH = 18;
132const CARD_NAME_CELLS = CARD_WIDTH - 4;
133const PARENT_NAME_CELLS = 28;
134
135/** A thing's emoji and name, with the apps' placeholder for a thing with no emoji. */
136function label(emoji: string | null, name: string): string {
137 return `${emoji ?? NO_EMOJI} ${name}`;
138}
139
140/** The copy Button's label: what it copies, then how the copy went (only ever shorter). */
141function copyLabel(blocked: EverythingsBlocked): string {
142 if (blocked.copy === 'copied') return 'Copied';
143 if (blocked.copy === 'failed') return 'Copy failed';
144 return blocked.rules.length === 1 ? 'Copy the rule' : 'Copy the rules';
145}
146
147export function drawPane(
148 els: ElementTable,
149 surface: RenderSurface,
150 snap: PaneSnapshot,
151 act: PaneActions,
152): RenderElement {
153 const { Box, Text, Button } = els;
154 const { view, load, cache, blocked, writes } = snap;
155 const page = view.kind === 'thing' ? pageOf(cache, view.thingId) : null;
156 const grid = view.kind === 'grid' ? gridOf(cache, view.workspaceId) : null;
157 const search = view.kind === 'search' && view.query ? searchOf(cache, view.query) : null;
158 const landing = grid?.landing ?? null;
159 const hasData =
160 page !== null || search !== null || (landing !== null && (landing.things.length > 0 || landing.isListed));
161
162 const failure = load.error ?? (load.isNotConnected ? NOT_CONNECTED : null);
163 const retry = <Button key="retry" label="Retry" onPress={act.refresh} />;
164 // The pane will not fill this screen by itself: its reads are blocked, or the last one failed.
165 const isSettled = blocked !== null || failure !== null;
166 // Asking Claude helps then: its own calls reach the connector under whatever name it runs.
167 const canAsk = isSettled;
168 const isAsked = snap.asked !== null && snap.asked === askKey(view, cache);
169 // The reads' note, then a refused write's, both when both are up.
170 const readNote = blocked !== null && !snap.isNoteDismissed ? blocked : null;
171 const writeNote = writes.blocked;
172
173 // A screen with nothing to draw says why and offers to ask Claude for it.
174 let unseen: RenderElement | null = null;
175 if (grid !== null && !hasData && canAsk) {
176 const workspaceId = landing?.workspaceId ?? null;
177 unseen = drawAsk(els, {
178 line: landing === null ? NOTHING_SEEN : WORKSPACE_UNLISTED,
179 label: landing === null ? 'Ask Claude to show your workspace' : 'Ask Claude to list it',
180 asked: landing === null ? WORKSPACE_ASKED_FIRST : WORKSPACE_ASKED,
181 isAsked,
182 onPress: () => act.ask({ kind: 'workspace', id: workspaceId }),
183 });
184 } else if (view.kind === 'search' && view.query && !hasData && canAsk) {
185 const query = view.query;
186 unseen = drawAsk(els, {
187 line: SEARCH_UNRUN,
188 label: 'Ask Claude to search',
189 asked: SEARCH_ASKED,
190 isAsked,
191 onPress: () => act.ask({ kind: 'search', query }),
192 });
193 } else if (view.kind === 'thing' && !hasData && canAsk) {
194 const thingId = view.thingId;
195 unseen = drawAsk(els, {
196 line: THING_NOT_SEEN,
197 label: 'Ask Claude to open it',
198 asked: THING_ASKED,
199 isAsked,
200 onPress: () => act.ask({ kind: 'thing', id: thingId }),
201 });
202 }
203
204 // No server answered and the session's calls carried nothing: the notice, and the ask under it.
205 if (load.isNotConnected && !hasData) {
206 return (
207 <Box flexDirection="column" rowGap={1}>
208 <Box flexDirection="row" columnGap={1}>
209 <Text>{NOT_CONNECTED}</Text>
210 <Button key="retry" label="Retry" onPress={act.refresh} />
211 </Box>
212 {unseen}
213 </Box>
214 );
215 }
216
217 // A read in flight shows on the Refresh Button, and a failed read's line
218 // sits below the page: neither adds or drops a line above the cards, so
219 // nothing moves under the pointer while a read runs.
220 return (
221 <Box flexDirection="column" rowGap={1}>
222 {drawHeader(els, surface, snap, page, grid, act)}
223 {readNote !== null && drawNote(els, [BLOCKED_NOTE, UNBLOCK_HOW], readNote, 'reads', act)}
224 {writeNote !== null && drawNote(els, [WRITE_UNBLOCK_HOW[writeNote.on]], writeNote, 'writes', act)}
225 {unseen}
226 {landing !== null && hasData && drawGrid(els, surface, landing, act)}
227 {page !== null && drawThing(els, surface, page, writes, canAsk, isAsked, act)}
228 {view.kind === 'search' && drawSearch(els, surface, snap, search, unseen !== null || failure !== null, act)}
229 {view.kind === 'inbox' && drawInbox(els, surface, view.list, snap.inbox, act)}
230 {failure !== null && (
231 <Box flexDirection="row" columnGap={1}>
232 <Text dimColor>{hasData ? `${FROM_CALLS} ${failure}` : failure}</Text>
233 {retry}
234 </Box>
235 )}
236 </Box>
237 );
238}
239
240function drawHeader(
241 els: ElementTable,
242 surface: RenderSurface,
243 snap: PaneSnapshot,
244 page: DrawnPage | null,
245 grid: DrawnGrid | null,
246 act: PaneActions,
247): RenderElement {
248 const { Box, Text, Button } = els;
249 const { view, cache, isFollowing } = snap;
250 const workspaces = grid?.workspaces ?? cache.workspaces;
251 const current = grid?.landing?.workspaceId ?? view.workspaceId;
252 // The phone draws no Select (its table has none to draw), so it gets Buttons.
253 const Select = surface !== 'mobile' && 'Select' in els ? els.Select : null;
254 const picked = workspaces.some(ws => ws.id === current) ? (current ?? undefined) : undefined;
255
256 let left: RenderElement | null = null;
257 if (view.kind === 'inbox') {
258 left = (
259 <Box flexDirection="row" columnGap={1} alignItems="center">
260 <Button key="back" label={GRID_GLYPH} onPress={act.back} />
261 <Text bold>{INBOX_TITLE[view.list]}</Text>
262 </Box>
263 );
264 } else if (view.kind === 'search') {
265 left = (
266 <Box flexDirection="row" columnGap={1} alignItems="center">
267 <Button key="back" label={GRID_GLYPH} onPress={act.back} />
268 {view.query !== '' && <Text bold>{`Search: ${view.query}`}</Text>}
269 </Box>
270 );
271 } else if (view.kind === 'thing') {
272 // The breadcrumb: the workspace's grid, then the thing this one sits under.
273 const parent = page?.parent ?? null;
274 left = (
275 <Box flexDirection="row" columnGap={1} alignItems="center">
276 <Button key="back" label={GRID_GLYPH} onPress={act.back} />
277 {parent !== null && <Text dimColor>{CHEVRON}</Text>}
278 {parent !== null && (
279 <Button
280 key="parent"
281 label={label(parent.emoji, clip(parent.name, PARENT_NAME_CELLS))}
282 onPress={() => act.openThing(parent.id)}
283 />
284 )}
285 </Box>
286 );
287 } else if (workspaces.length > 0 && Select) {
288 left = (
289 <Select
290 key="workspace"
291 options={workspaces.map(ws => ({ value: ws.id, label: ws.name }))}
292 value={picked}
293 onSelect={act.openWorkspace}
294 />
295 );
296 } else if (workspaces.length > 0) {
297 left = (
298 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
299 {workspaces.map(ws => (
300 <Button
301 key={`workspace:${ws.id}`}
302 plain
303 dimColor={ws.id !== current}
304 label={ws.name}
305 onPress={() => act.openWorkspace(ws.id)}
306 />
307 ))}
308 </Box>
309 );
310 }
311
312 return (
313 <Box flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2}>
314 <Box flexShrink={1}>{left}</Box>
315 <Box flexDirection="row" columnGap={1}>
316 <Button key="follow" label={isFollowing ? 'Follow: on' : 'Follow: paused'} onPress={act.toggleFollow} />
317 <Button key="refresh" label={snap.load.isLoading ? 'Loading…' : 'Refresh'} onPress={act.refresh} />
318 </Box>
319 </Box>
320 );
321}
322
323/**
324 * Why Claude Code stopped a read or a write, and the permission rules that
325 * let it through, which a Button copies. How the copy went shows in that
326 * Button's label, which only gets shorter, so the note never grows a line.
327 * The reads' note, dismissed, stays hidden until a Refresh the person
328 * presses is refused; a write's goes until the next refused write.
329 */
330function drawNote(
331 els: ElementTable,
332 lines: string[],
333 blocked: EverythingsBlocked,
334 on: 'reads' | 'writes',
335 act: PaneActions,
336): RenderElement {
337 const { Box, Text, Button } = els;
338 const prefix = on === 'reads' ? '' : 'write-';
339 return (
340 <Box flexDirection="column">
341 {lines.map(line => (
342 <Text dimColor>{line}</Text>
343 ))}
344 {blocked.rules.map(rule => (
345 <Text>{rule}</Text>
346 ))}
347 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
348 <Button
349 key={`copy-${prefix}rules`}
350 label={copyLabel(blocked)}
351 onPress={press => act.copyRules(on, press.surface)}
352 />
353 <Button
354 key={`dismiss-${prefix}note`}
355 label="Dismiss"
356 onPress={on === 'reads' ? act.dismissNote : act.dismissWriteNote}
357 />
358 </Box>
359 </Box>
360 );
361}
362
363/** A page known only by name: why, and a Button asking Claude to open it, or the line saying it asked. */
364function drawAsk(
365 els: ElementTable,
366 ask: { line: string; label: string; asked: string; isAsked: boolean; onPress: () => void },
367): RenderElement {
368 const { Box, Text, Button } = els;
369 return (
370 <Box flexDirection="column" rowGap={1}>
371 <Text dimColor>{ask.line}</Text>
372 {ask.isAsked ? (
373 <Text dimColor>{ask.asked}</Text>
374 ) : (
375 <Box flexDirection="row">
376 <Button key="ask" variant="primary" label={ask.label} onPress={ask.onPress} />
377 </Box>
378 )}
379 </Box>
380 );
381}
382
383/**
384 * A workspace's things as cards of one width that wrap into aligned
385 * columns, the emoji on its own line and the name below it cut to fit. Only
386 * a Button takes a press, so off the terminal each card is one Button, its
387 * label the emoji and the name on two lines. On the terminal the card is a
388 * bordered Box whose border lights under the pointer, and the name is the
389 * Button.
390 */
391function drawCards(
392 els: ElementTable,
393 surface: RenderSurface,
394 refs: EverythingsThingRef[],
395 act: PaneActions,
396): RenderElement {
397 const { Box, Text, Button } = els;
398 const isTerminal = surface === 'terminal';
399 return (
400 <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={isTerminal ? 0 : 1}>
401 {refs.map(ref => {
402 const emoji = ref.emoji ?? NO_EMOJI;
403 const name = clip(ref.name, CARD_NAME_CELLS);
404 const open = () => act.openThing(ref.id);
405 return isTerminal ? (
406 <Box
407 key={`card:${ref.id}`}
408 flexDirection="column"
409 alignItems="center"
410 width={CARD_WIDTH}
411 paddingX={1}
412 borderStyle="round"
413 borderDimColor
414 hover={{ borderColor: 'cyan', borderDimColor: false }}
415 >
416 <Text>{emoji}</Text>
417 <Button key={`tile:${ref.id}`} plain label={name} onPress={open} />
418 </Box>
419 ) : (
420 <Box key={`card:${ref.id}`} flexDirection="column" alignItems="stretch" width={CARD_WIDTH}>
421 <Button key={`tile:${ref.id}`} label={`${emoji}\n${name}`} onPress={open} />
422 </Box>
423 );
424 })}
425 </Box>
426 );
427}
428
429/**
430 * A sub-thing's row label: its marks' emojis when it carries any, then a
431 * pipe, then its emoji and name in full (`✅ ⚠️ | 📄 Name`). The emojis follow
432 * the mark row's order (the workspace's default marks in their order, then
433 * the others), at most ROW_MARKS_SHOWN and then "+n". Marks unknown or none:
434 * the emoji and name alone.
435 */
436function rowLabel(child: DrawnChild, defaults: EverythingsDefaultMark[] | null): string {
437 const named = label(child.emoji, child.name);
438 if (!child.marks || child.marks.length === 0) return named;
439 const order = new Map((defaults ?? []).map((mark, index) => [mark.name, index]));
440 const sorted = [
441 ...child.marks
442 .filter(mark => order.has(mark.name))
443 .sort((a, b) => (order.get(a.name) ?? 0) - (order.get(b.name) ?? 0)),
444 ...child.marks.filter(mark => !order.has(mark.name)),
445 ];
446 const shown = sorted
447 .slice(0, ROW_MARKS_SHOWN)
448 .map(mark => mark.emoji)
449 .join(' ');
450 const more = sorted.length - ROW_MARKS_SHOWN;
451 return `${shown}${more > 0 ? ` +${more}` : ''} | ${named}`;
452}
453
454/**
455 * A thing's sub-things as a list, one per row with a blank row between, so
456 * each whole name shows. Each row is one Button stretched to the pane's
457 * width, so the whole row takes the press. Off the terminal it is the
458 * surface's filled button, which sets each row on its own background; the
459 * terminal draws it plain. Its top padding and the page's gap before it
460 * leave two blank rows above the list.
461 */
462function drawRows(
463 els: ElementTable,
464 surface: RenderSurface,
465 list: { key: string; prefix: string },
466 rows: { id: string; label: string; key?: string }[],
467 act: PaneActions,
468): RenderElement {
469 const { Box, Button } = els;
470 return (
471 <Box key={list.key} flexDirection="column" alignItems="stretch" rowGap={1} paddingTop={1}>
472 {rows.map(row =>
473 surface === 'terminal' ? (
474 <Button key={`${list.prefix}:${row.key ?? row.id}`} plain label={row.label} onPress={() => act.openThing(row.id)} />
475 ) : (
476 <Button key={`${list.prefix}:${row.key ?? row.id}`} label={row.label} onPress={() => act.openThing(row.id)} />
477 ),
478 )}
479 </Box>
480 );
481}
482
483/** A hit's row label: a sub-thing's (marks, pipe, emoji, name), then the workspace it is in. */
484function hitLabel(hit: DrawnHit, defaults: EverythingsDefaultMark[] | null): string {
485 const row = rowLabel(hit, defaults);
486 return hit.workspaceName !== null ? `${row} · ${hit.workspaceName}` : row;
487}
488
489/**
490 * A search's hits as rows, drawn as a thing's sub-things are, even when
491 * there is one, each naming its workspace; a press opens the thing. With no
492 * query, one line says to type one. `isExplained`: the ask or a failure line
493 * already says why no hits show.
494 */
495function drawSearch(
496 els: ElementTable,
497 surface: RenderSurface,
498 snap: PaneSnapshot,
499 search: DrawnSearch | null,
500 isExplained: boolean,
501 act: PaneActions,
502): RenderElement | null {
503 const { Box, Text } = els;
504 if (snap.view.kind !== 'search') return null;
505 if (snap.view.query === '') return <Text dimColor>{NO_QUERY}</Text>;
506 if (search === null) return isExplained ? null : <Text dimColor>{SEARCHING}</Text>;
507 if (search.hits.length === 0) return <Text dimColor>{NO_HITS}</Text>;
508 const rows = search.hits.map(hit => ({
509 id: hit.id,
510 label: hitLabel(hit, hit.workspaceId !== null ? (snap.cache.defaultMarks[hit.workspaceId] ?? null) : null),
511 }));
512 return (
513 <Box flexDirection="column">
514 {drawRows(els, surface, { key: 'hits', prefix: 'hit' }, rows, act)}
515 {search.truncated && <Text dimColor>{`Showing ${search.hits.length} of ${search.count}.`}</Text>}
516 </Box>
517 );
518}
519
520/** The most of an item's question or comment a row shows. */
521const INBOX_TEXT_CELLS = 120;
522
523/**
524 * An inbox screen: the band's list as rows, drawn as a search's hits are,
525 * each the thing's emoji and name, its workspace, then the question or the
526 * comment on one line. A press opens the thing. A list not known yet says why.
527 */
528function drawInbox(
529 els: ElementTable,
530 surface: RenderSurface,
531 list: EverythingsInboxList,
532 inbox: EverythingsInbox,
533 act: PaneActions,
534): RenderElement {
535 const { Text } = els;
536 const items = inbox[list];
537 if (items === null) return <Text dimColor>{inbox.isRefused ? INBOX_REFUSED : INBOX_UNREAD}</Text>;
538 if (items.length === 0) return <Text dimColor>{INBOX_EMPTY[list]}</Text>;
539 const rows = items.map(item => {
540 const where = item.workspaceName !== null ? ` · ${item.workspaceName}` : '';
541 const text = item.text !== '' ? `: ${clip(item.text, INBOX_TEXT_CELLS)}` : '';
542 return { id: item.thingId, key: item.key, label: `${label(item.emoji, item.name)}${where}${text}` };
543 });
544 return drawRows(els, surface, { key: `inbox-${list}`, prefix: list === 'questions' ? 'question' : 'job' }, rows, act);
545}
546
547/** What a press of a count on the band does: the pane opens on that list. */
548export type BandActions = { open: (list: EverythingsInboxList) => void };
549
550/** The band's Button label for a count. */
551export function bandLabel(list: EverythingsInboxList, count: number): string {
552 const n = countText(count);
553 return list === 'questions'
554 ? `❓ ${n} open question${count === 1 ? '' : 's'}`
555 : `🤖 ${n} @Agent job${count === 1 ? '' : 's'}`;
556}
557
558/**
559 * The band above the prompt: a Button per count above zero. Null when both
560 * are zero or unknown, so the hook leaves the band to the engine.
561 */
562export function drawBand(els: ElementTable, inbox: EverythingsInbox, act: BandActions): RenderElement | null {
563 const { Box, Text, Button } = els;
564 const counts = countsOf(inbox);
565 const shown = (['questions', 'jobs'] as const).filter(list => (counts[list] ?? 0) > 0);
566 if (shown.length === 0) return null;
567 return (
568 <Box flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
569 <Text dimColor>Everythings</Text>
570 {shown.map(list => (
571 <Button key={`inbox-${list}`} label={bandLabel(list, counts[list] ?? 0)} onPress={() => act.open(list)} />
572 ))}
573 </Box>
574 );
575}
576
577function drawGrid(
578 els: ElementTable,
579 surface: RenderSurface,
580 landing: NonNullable<DrawnGrid['landing']>,
581 act: PaneActions,
582): RenderElement {
583 const { Box, Text } = els;
584 if (landing.things.length === 0) return <Text dimColor>This workspace is empty.</Text>;
585 return (
586 <Box flexDirection="column">
587 {drawCards(els, surface, landing.things, act)}
588 {landing.truncated && <Text dimColor>{`Showing ${landing.things.length} of ${landing.count}.`}</Text>}
589 </Box>
590 );
591}
592
593/** What the line under the marks says about the latest press on this thing. */
594function markLine(last: NonNullable<EverythingsWrites['last']>): string {
595 const mark = `${last.emoji} ${last.name}`;
596 switch (last.did) {
597 case 'adding':
598 return `Adding ${mark}…`;
599 case 'removing':
600 return `Removing ${mark}…`;
601 case 'added':
602 return `Added your ${mark} mark.`;
603 case 'removed':
604 return `Removed your ${mark} mark.`;
605 case 'already-on':
606 return `${mark} was already on.`;
607 case 'already-off':
608 return `${mark} was already off.`;
609 }
610}
611
612/**
613 * The thing's marks as one row of Buttons in a fixed order: the
614 * workspace's default marks in their own order, carried or not, then the
615 * marks it carries that are not among them (the carried marks alone while
616 * the defaults are unknown). A press toggles the person's own mark and
617 * changes that Button's label where it sits, so nothing moves under the
618 * pointer. The label is the emoji, then the count when the thing carries
619 * it; the person's own mark is primary and one nobody carries is dim. A
620 * Button whose call is in flight draws dim and does nothing. Under the row, one dim line: a failure or a
621 * refusal, else what the latest press did. A thing that carries no marks
622 * shows its row of dim defaults and no line.
623 */
624function drawMarks(els: ElementTable, page: DrawnPage, writes: EverythingsWrites, act: PaneActions): RenderElement {
625 const { Box, Text, Button } = els;
626 const carried = page.marks ?? [];
627 const byName = new Map(carried.map(mark => [mark.name, mark]));
628 const defaults = page.defaultMarks ?? [];
629 const isDefault = new Set(defaults.map(mark => mark.name));
630 const row = [
631 ...defaults.map(mark => byName.get(mark.name) ?? { name: mark.name, emoji: mark.emoji, count: 0, mine: false }),
632 ...carried.filter(mark => !isDefault.has(mark.name)),
633 ];
634 const error = writes.error?.thingId === page.id && writes.error.on === 'mark' ? writes.error.text : null;
635 const last = writes.last?.thingId === page.id ? writes.last : null;
636 const line = error ?? (last !== null ? markLine(last) : null);
637 const isPending = (name: string) => writes.pending[`${page.id} ${name}`] !== undefined;
638 return (
639 <Box flexDirection="column">
640 {row.length > 0 && (
641 <Box key="marks" flexDirection="row" flexWrap="wrap" columnGap={1}>
642 {row.map(mark => {
643 const label = `${mark.emoji}${mark.count > 0 ? ` ${mark.count}` : ''}`;
644 const press = () => act.mark(page.id, { name: mark.name, emoji: mark.emoji });
645 const isDim = isPending(mark.name) || mark.count === 0 || undefined;
646 return mark.mine ? (
647 <Button key={`mark:${mark.name}`} variant="primary" dimColor={isDim} label={label} onPress={press} />
648 ) : (
649 <Button key={`mark:${mark.name}`} plain dimColor={isDim} label={label} onPress={press} />
650 );
651 })}
652 </Box>
653 )}
654 {line !== null && <Text dimColor>{line}</Text>}
655 </Box>
656 );
657}
658
659/**
660 * The comments, then a field for the person's own comment, top level and
661 * sent as typed. Each comment posted draws that thing a fresh field (its
662 * key counts the thing's posts), so it comes back empty; a failure leaves
663 * the field as typed, with its line under it. The title has a blank row
664 * more above it than the page's other sections, so the comments read apart
665 * from the sub-things over them. The
666 * phone's table has no field to draw, so it shows the comments alone.
667 */
668function drawComments(
669 els: ElementTable,
670 surface: RenderSurface,
671 page: DrawnPage,
672 writes: EverythingsWrites,
673 act: PaneActions,
674): RenderElement | null {
675 const { Box, Text } = els;
676 const comments = page.comments;
677 if (comments === null) return null;
678 const Input = surface !== 'mobile' && 'Input' in els ? els.Input : null;
679 const error = writes.error?.thingId === page.id && writes.error.on === 'comment' ? writes.error.text : null;
680 if (comments.list.length === 0 && Input === null) return null;
681 return (
682 <Box flexDirection="column" rowGap={1}>
683 {comments.list.length > 0 && (
684 <Box key="comments-title" paddingTop={1}>
685 <Text dimColor>{`Comments (${comments.count})`}</Text>
686 </Box>
687 )}
688 {comments.list.map(comment => (
689 <Box flexDirection="column" paddingLeft={comment.parentId !== null ? 2 : 0}>
690 <Box flexDirection="row" columnGap={1}>
691 <Text bold>{comment.authorName}</Text>
692 {comment.byAgent !== null && <Text dimColor>{`via ${comment.byAgent}`}</Text>}
693 </Box>
694 {comment.text !== '' && <Text>{comment.text}</Text>}
695 </Box>
696 ))}
697 {comments.truncated && <Text dimColor>More comments in the app.</Text>}
698 {/* Two blank rows above the field: the gap before it, and this padding. */}
699 {Input !== null && (
700 <Box flexDirection="column" paddingTop={1}>
701 <Input
702 key={`comment:${page.id}:${writes.posted[page.id] ?? 0}`}
703 placeholder="Add a comment"
704 submitLabel={writes.commenting[page.id] !== undefined ? 'sending…' : 'send'}
705 onSubmit={text => act.comment(page.id, text)}
706 />
707 {error !== null && <Text dimColor>{error}</Text>}
708 </Box>
709 )}
710 </Box>
711 );
712}
713
714function drawThing(
715 els: ElementTable,
716 surface: RenderSurface,
717 page: DrawnPage,
718 writes: EverythingsWrites,
719 canAsk: boolean,
720 isAsked: boolean,
721 act: PaneActions,
722): RenderElement {
723 const { Box, Text, Link, Markdown } = els;
724 const { children, content, links } = page;
725
726 return (
727 <Box flexDirection="column" rowGap={1}>
728 <Box flexDirection="column">
729 <Text bold>{label(page.emoji, page.name)}</Text>
730 {page.agent !== null && <Text dimColor>{`by ${page.agent}`}</Text>}
731 {page.workspaceId !== null && <Link href={thingUrl(page.workspaceId, page.id)} label="Open in app" />}
732 </Box>
733 {page.marks !== null && drawMarks(els, page, writes, act)}
734 {page.isNameOnly &&
735 canAsk &&
736 drawAsk(els, {
737 line: page.isPruned ? THING_PRUNED : THING_UNREAD,
738 label: 'Ask Claude to open it',
739 asked: THING_ASKED,
740 isAsked,
741 onPress: () => act.ask({ kind: 'thing', id: page.id }),
742 })}
743 {content !== null &&
744 (links.length > 0 ? (
745 // Links to other things open them here; every other link opens as usual.
746 <Markdown
747 key="content"
748 text={content}
749 pressableLinks={links}
750 onLinkPress={link => {
751 const linked = thingIdFromLink(link.href);
752 if (linked) act.openThing(linked);
753 }}
754 />
755 ) : (
756 <Markdown key="content" text={content} />
757 ))}
758 {page.isContentCut && <Text dimColor>Cut at 10,000 characters. Open it in the app for the rest.</Text>}
759 {page.request !== null && drawRequest(els, page.request)}
760 {children !== null && children.things.length > 0 && (
761 <Box flexDirection="column">
762 {drawRows(
763 els,
764 surface,
765 { key: 'sub-things', prefix: 'child' },
766 children.things.map(child => ({ id: child.id, label: rowLabel(child, page.defaultMarks) })),
767 act,
768 )}
769 {children.truncated && (
770 <Text dimColor>{`Showing ${children.things.length} of ${children.count}.`}</Text>
771 )}
772 </Box>
773 )}
774 {drawComments(els, surface, page, writes, act)}
775 </Box>
776 );
777}
778
779const VIA: Record<string, string> = { web: 'on the web', mobile: 'on the phone', mcp: 'in a chat' };
780
781/**
782 * Shown for the person to read, with nothing to press, on purpose: the pane
783 * writes with the agent's own token, so an answer sent from here would be
784 * indistinguishable from the agent answering its own question. The person
785 * answers in the app.
786 */
787function drawRequest(els: ElementTable, request: EverythingsRequest): RenderElement {
788 const { Box, Text } = els;
789 const answer = request.answer;
790 const chosen = answer?.optionId ?? null;
791 const by = [answer?.userName ? `by ${answer.userName}` : '', answer?.via ? (VIA[answer.via] ?? '') : '']
792 .filter(Boolean)
793 .join(' ');
794 const answered = answer
795 ? `Answered${answer.optionLabel ? `: ${answer.optionLabel}` : ''}` +
796 `${answer.comment ? ` "${answer.comment}"` : ''}${by ? ` (${by})` : ''}`
797 : null;
798 return (
799 <Box flexDirection="column">
800 <Text bold>{request.createdBy ? `Question from ${request.createdBy}` : 'Question'}</Text>
801 <Text>{request.question}</Text>
802 {request.options.map(option => (
803 <Text dimColor={chosen !== null && option.id !== chosen}>
804 {`${option.id === chosen ? '✓' : '•'} ${option.label}${option.description ? `: ${option.description}` : ''}`}
805 </Text>
806 ))}
807 {answered !== null && <Text>{answered}</Text>}
808 {answer === null && request.status === 'open' && <Text dimColor>Waiting for an answer in the app.</Text>}
809 {answer === null && request.status === 'dismissed' && <Text dimColor>Dismissed.</Text>}
810 </Box>
811 );
812}
813hooks/ports.ts 91 lines1// What the mod's logic may ask of the engine, as plain functions.
2//
3// The engine follows `$` only into functions declared in the same file as
4// the hook that received it; `claude plugin validate` refuses a `$` handed
5// to an imported function. So register.tsx holds every hook and builds a
6// Ports from its `$` (the one place `$.mcp`, `$.store`, `$.ui` and
7// `$.state` are spelled out), and the other files are logic written against
8// this type.
9
10import type { McpToolResult, RenderSurface } from 'claude-code';
11
12import type {
13 EverythingsBlocked,
14 EverythingsCache,
15 EverythingsInbox,
16 EverythingsLoad,
17 EverythingsView,
18 EverythingsWake,
19 EverythingsWrites,
20} from '../types';
21
22/** What `$.mcp.connect` answers: the name `$.mcp.call` takes, or why not. */
23export type Connected = { isConnected: true; server: string } | { isConnected: false; message: string };
24
25/** One value of the mod's `$.state`: read, or changed from what stands (answering the value written). */
26export type Cell<T> = {
27 read: () => Promise<T>;
28 update: (change: (value: T) => T) => Promise<T>;
29};
30
31/** Equal values: the same one, or, for a small value, the same JSON. */
32export function isSameJson<T>(a: T, b: T): boolean {
33 return Object.is(a, b) || JSON.stringify(a) === JSON.stringify(b);
34}
35
36/**
37 * A Cell that skips a write which would leave the value as it stands. Every
38 * write redraws the pane, and a redraw for nothing can replace the tree
39 * under a second quick press. A skipped write is as if it ran at the read,
40 * where the value already stood as the change would leave it.
41 */
42export function cellOf<T>(
43 get: () => Promise<T>,
44 put: (change: (value: T) => T) => Promise<T>,
45 isSame: (a: T, b: T) => boolean = isSameJson,
46): Cell<T> {
47 return {
48 read: get,
49 update: async change => {
50 const now = await get();
51 return isSame(now, change(now)) ? now : put(change);
52 },
53 };
54}
55
56export type Ports = {
57 view: Cell<EverythingsView>;
58 cache: Cell<EverythingsCache>;
59 load: Cell<EverythingsLoad>;
60 follow: Cell<boolean>;
61 server: Cell<string | null>;
62 blocked: Cell<EverythingsBlocked | null>;
63 noteDismissed: Cell<boolean>;
64 asked: Cell<string | null>;
65 fresh: Cell<Record<string, number>>;
66 defaultsAsked: Cell<string[]>;
67 writes: Cell<EverythingsWrites>;
68 inbox: Cell<EverythingsInbox>;
69 wake: Cell<EverythingsWake>;
70 /** `$.clock.now()`: ms since the epoch. */
71 now: () => Promise<number>;
72 /** `$.mcp.call`: rejects when no server answers under that name. */
73 mcpCall: (server: string, tool: string, args: Record<string, unknown>) => Promise<McpToolResult>;
74 /** `$.mcp.connect` on this plugin's own .mcp.json server. */
75 mcpConnect: (server: string) => Promise<Connected>;
76 storeGet: (key: string) => Promise<unknown>;
77 storeSet: (key: string, value: unknown) => Promise<void>;
78 /** Opens (or retitles) the pane. */
79 openPane: () => Promise<void>;
80 isPaneOpen: () => Promise<boolean>;
81 /** `$.prompt.submit` as the person's own words: true once it entered, false when a hook dropped it. */
82 askClaude: (text: string) => Promise<boolean>;
83 /**
84 * `$.prompt.submit` framed as the plugin's own message (not the person's
85 * words): the wake on an answer. Resolves once the prompt's turn starts.
86 */
87 wakeSession: (text: string) => Promise<void>;
88 /** `$.ui.copy` on the surface a press came from: true when the text reached a clipboard. */
89 copy: (text: string, surface: RenderSurface) => Promise<boolean>;
90};
91hooks/rows.tsx 170 lines1// Transcript rows: an Everythings write the agent made reads as one compact
2// row, the verb and the thing ("updated 🍋 Lemon cake"), its name a Link
3// to the thing in the app, in place of the tool's name over its JSON.
4//
5// register.tsx's `ui.render` hooks on `ToolUse` and `ToolResult` call these
6// for the write tools in ROW_TOOLS. The two must agree: where the call row
7// draws compact, the result block under it draws nothing; where it does
8// not, both are the engine's. A call is drawn compact only once it resolved
9// without an error, on a server known as Everythings (server.ts: any server
10// for a tool name only Everythings has, the learned server for a shared
11// name such as add_mark), with a JSON answer that does not report failure.
12// Anything else, a running or interrupted call among them, keeps the
13// engine's own row. The thing's name and emoji come from the call itself,
14// else from the session cache (cache.ts); without a name the row shows the
15// thing's id.
16
17import type { ElementTable, RenderElement } from 'claude-code';
18
19import type { EverythingsCache, EverythingsCachedThing } from '../types';
20import { clip, isRecord, NO_EMOJI, outputData, splitToolName, str, thingUrl, UNIQUE_TOOLS } from './data';
21import type { Ports } from './ports';
22import { isLearnedServer } from './server';
23
24/** The write tools whose transcript rows are drawn compact, with the verb each row reads. */
25export const ROW_VERBS: Readonly<Record<string, string>> = {
26 create_thing: 'created',
27 update_thing: 'updated',
28 move_thing: 'moved',
29 delete_thing: 'trashed',
30 restore_thing: 'restored',
31 copy_thing: 'copied',
32 add_mark: 'marked',
33 remove_mark: 'unmarked',
34 add_comment: 'commented on',
35 request_input: 'asked about',
36};
37
38/** Writes whose thing is the one in the answer: the new thing, the copy, the thing a question went on. */
39const ID_FROM_ANSWER: ReadonlySet<string> = new Set(['create_thing', 'copy_thing', 'request_input']);
40
41/** The most cells a thing's name takes in a row. */
42const NAME_CELLS = 80;
43
44/** What one compact row says. `href` is null when the thing's workspace is unknown, or it went to Trash. */
45export type WriteRow = { verb: string; thing: string; suffix: string | null; href: string | null };
46
47/**
48 * The tool's short name and its JSON answer when the call is drawn compact,
49 * else null: the engine's own row stays.
50 */
51export async function judgeRow(
52 p: Ports,
53 tool: string,
54 isErrored: boolean,
55 output: unknown,
56): Promise<{ name: string; data: Record<string, unknown> } | null> {
57 if (isErrored) return null;
58 const parts = splitToolName(tool);
59 if (!parts || !(parts.name in ROW_VERBS)) return null;
60 if (!UNIQUE_TOOLS.has(parts.name) && !(await isLearnedServer(p, parts.server))) return null;
61 const data = outputData(output);
62 if (!data || data.success === false || data.isError === true) return null;
63 return { name: parts.name, data };
64}
65
66/** The compact row of a resolved call, or null when the engine's own row stays (judgeRow). */
67export async function writeRow(
68 p: Ports,
69 call: { tool: string; input: unknown; output?: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean },
70): Promise<WriteRow | null> {
71 if (call.isRunning || call.isInterrupted) return null;
72 const judged = await judgeRow(p, call.tool, call.isErrored, call.output);
73 if (!judged) return null;
74 const { name, data } = judged;
75 const input = isRecord(call.input) ? call.input : {};
76 const cache = await p.cache.read();
77
78 const fromInput = id(input.thingId);
79 const fromAnswer = id(data.thingId);
80 const thingId = ID_FROM_ANSWER.has(name) ? (fromAnswer ?? fromInput) : (fromInput ?? fromAnswer);
81 const known = thingId ? cache.things[thingId] : undefined;
82 // A copy's emoji is its source's.
83 const source = name === 'copy_thing' && fromInput ? cache.things[fromInput] : undefined;
84
85 const thingName =
86 (name === 'create_thing' ? str(input.name) : null) ??
87 (name === 'update_thing' || name === 'copy_thing' ? str(data.name) : null) ??
88 (name === 'update_thing' ? str(input.name) : null) ??
89 known?.name ??
90 (name === 'request_input' ? str(input.name) : null);
91 const emoji = emojiOf(name, input, data, known, source);
92 const thing = thingName ? `${emoji ?? NO_EMOJI} ${clip(thingName, NAME_CELLS)}` : (thingId ?? 'a thing');
93
94 const workspaceId =
95 (name === 'create_thing' ? (id(data.workspaceId) ?? id(input.workspaceId)) : null) ??
96 (name === 'copy_thing' ? id(input.targetWorkspaceId) : null) ??
97 known?.workspaceId ??
98 (name === 'request_input' ? (id(input.workspaceId) ?? workspaceOfUrl(data.url, thingId)) : null);
99 const href = thingId && workspaceId && name !== 'delete_thing' ? thingUrl(workspaceId, thingId) : null;
100
101 return { verb: ROW_VERBS[name] as string, thing, suffix: suffixOf(name, input, data, known, cache), href };
102}
103
104/** The row: the verb, then the thing as a Link to it in the app, then what was done to it. */
105export function drawRow(els: ElementTable, row: WriteRow): RenderElement {
106 const { Box, Text, Link } = els;
107 return (
108 <Box flexDirection="row" columnGap={1}>
109 <Text>{row.verb}</Text>
110 {row.href !== null ? <Link href={row.href} label={row.thing} /> : <Text bold>{row.thing}</Text>}
111 {row.suffix !== null && <Text>{row.suffix}</Text>}
112 </Box>
113 );
114}
115
116function id(value: unknown): string | null {
117 return str(value, 100);
118}
119
120/** The thing's emoji: what the call set, else what the session knows; null for none. */
121function emojiOf(
122 name: string,
123 input: Record<string, unknown>,
124 data: Record<string, unknown>,
125 known: EverythingsCachedThing | undefined,
126 source: EverythingsCachedThing | undefined,
127): string | null {
128 if (name === 'update_thing' && 'emoji' in data) return str(data.emoji, 32);
129 if (name === 'create_thing') return str(input.emoji, 32);
130 if (name === 'update_thing' && typeof input.emoji === 'string') return str(input.emoji, 32);
131 return known?.emoji ?? source?.emoji ?? null;
132}
133
134/** The mark (emoji and name), the new place, or nothing. */
135function suffixOf(
136 name: string,
137 input: Record<string, unknown>,
138 data: Record<string, unknown>,
139 known: EverythingsCachedThing | undefined,
140 cache: EverythingsCache,
141): string | null {
142 if (name === 'add_mark') return markLabel(str(input.emoji, 32), str(input.name, 100));
143 if (name === 'remove_mark') {
144 const markName = str(input.name, 100);
145 const mark = known?.marks?.find(one => one.name === markName) ?? known?.rowMarks?.find(one => one.name === markName);
146 return markLabel(mark?.emoji ?? null, markName);
147 }
148 if (name === 'move_thing') {
149 const parentId = 'newParentId' in data ? id(data.newParentId) : id(input.newParentId);
150 if (!parentId) return 'to the top level';
151 const parent = cache.things[parentId];
152 return `under ${parent ? `${parent.emoji ?? NO_EMOJI} ${clip(parent.name, NAME_CELLS)}` : parentId}`;
153 }
154 return null;
155}
156
157/** `✅ done`, or the emoji alone when the mark is named by it. */
158function markLabel(emoji: string | null, markName: string | null): string | null {
159 if (emoji && markName && markName !== emoji) return `${emoji} ${markName}`;
160 return emoji ?? markName;
161}
162
163/** The workspace in the app link request_input answers with, when it is that thing's. */
164function workspaceOfUrl(url: unknown, thingId: string | null): string | null {
165 if (typeof url !== 'string' || !thingId) return null;
166 const match = /\/workspaces\/([^/?#]+)\/things\/([^/?#]+)/.exec(url);
167 if (!match || decodeURIComponent(match[2] as string) !== thingId) return null;
168 return id(decodeURIComponent(match[1] as string));
169}
170hooks/showThing.ts 78 lines1// The model-callable tool `mcp__everythings__show_thing`: when the person
2// asks to see a thing or a workspace, the model calls it and the pane opens
3// on it, drawn from what this session has seen and completed by one read of
4// the pane's own (none while Claude Code refuses the pane's reads). It
5// writes nothing. Its answer tells the model what the pane shows: when the
6// pane could not read the thing and the session knows no more than its
7// name, the model's own get_thing_view call fills the pane (the hook records
8// its result). register.tsx lists the tool at session start and serves its
9// calls.
10
11import type { ToolSpec } from 'claude-code';
12
13import { gridOf, pageOf } from './cache';
14import { openGrid, openThing, readSnapshot } from './nav';
15import { NOT_CONNECTED } from './pane';
16import type { Ports } from './ports';
17
18export const SHOW_THING: ToolSpec = {
19 name: 'show_thing',
20 description:
21 'Shows an Everythings thing or workspace to the person in the Everythings pane beside this conversation. ' +
22 'Call it when the person asks to see, show or open a thing ("show me my music thing") or a workspace: ' +
23 "pass the thing's id as thingId, or a workspace's id as workspaceId. With neither, the pane opens on " +
24 "the person's default workspace. Calling it only displays; it changes no data. Its answer says what the pane " +
25 'shows; when it says the pane could not load the thing, call get_thing_view (or get_workspace_view) ' +
26 'yourself and the pane shows its result. To read a thing for yourself, call get_thing instead.',
27 inputSchema: {
28 type: 'object',
29 properties: {
30 thingId: { type: 'string', description: 'The thing to show' },
31 workspaceId: { type: 'string', description: 'The workspace to show when no thing is named' },
32 },
33 additionalProperties: false,
34 },
35};
36
37function id(value: unknown): string | null {
38 return typeof value === 'string' && value.trim() ? value.trim() : null;
39}
40
41/** Said to the model while Claude Code refuses the pane's own reads. */
42const BLOCKED = "Claude Code refuses the pane's own reads in this session.";
43
44/** Opens the pane on what the call names; answers one line for the model. */
45export async function showThing(p: Ports, args: Record<string, unknown>): Promise<string> {
46 const thingId = id(args.thingId);
47 const workspaceId = id(args.workspaceId);
48 await p.openPane();
49 if (thingId) await openThing(p, thingId);
50 else await openGrid(p, workspaceId);
51
52 const snap = await readSnapshot(p);
53 const failure =
54 snap.blocked !== null ? BLOCKED : (snap.load.error ?? (snap.load.isNotConnected ? NOT_CONNECTED : null));
55 const why = snap.blocked !== null ? BLOCKED : `The pane's own read failed: ${failure}`;
56
57 if (thingId) {
58 const page = pageOf(snap.cache, thingId);
59 // A page known only by name shows nothing the model could point the person to.
60 const known = page !== null && !page.isNameOnly ? page : null;
61 if (failure === null) return page ? `The Everythings pane shows "${page.name}".` : 'The Everythings pane is open.';
62 if (known) {
63 return `The Everythings pane shows "${known.name}" as this session's calls carried it; sections no call carried are missing. ${why}`;
64 }
65 return `The Everythings pane could not load it: ${failure} Call get_thing_view with thingId "${thingId}" yourself; the pane shows its result.`;
66 }
67
68 const grid = gridOf(snap.cache, snap.view.kind === 'grid' ? snap.view.workspaceId : workspaceId);
69 const landing = grid.landing;
70 const name = grid.workspaces.find(ws => ws.id === landing?.workspaceId)?.name ?? 'the';
71 if (failure === null) return landing ? `The Everythings pane shows the "${name}" workspace.` : 'The Everythings pane is open.';
72 if (landing && (landing.things.length > 0 || landing.isListed)) {
73 return `The Everythings pane shows the things of the "${name}" workspace this session's calls carried. ${why}`;
74 }
75 const asked = workspaceId ? ` with workspaceId "${workspaceId}"` : '';
76 return `The Everythings pane could not load it: ${failure} Call get_workspace_view${asked} yourself; the pane shows its result.`;
77}
78hooks/server.ts 145 lines1// Which MCP server the pane's reads go to.
2//
3// The same Everythings server runs under different names: a UUID for the
4// claude.ai connector in the desktop app, `everythings` or
5// `claude_ai_Everythings` in a terminal, `plugin_everythings_everythings`
6// for this plugin's own .mcp.json entry. Reads try, in order: the name
7// learned from an observed call (this session's, else the one kept in the
8// store), then this plugin's own server through `$.mcp.connect`, then the
9// claude.ai connector's display name. The first that answers is used for the
10// rest of the session. Only a name learned from an observed call is kept in
11// the store for later sessions.
12//
13// Claude Code may refuse the pane's own call: under auto permission mode it
14// was seen refusing with "The server-side auto mode classifier gave no
15// verdict ...". A refusal ends the read at once, with no other name tried
16// and nothing retried; blocked.ts then stops the pane's reads until the
17// person presses Refresh.
18
19import type { McpToolResult } from 'claude-code';
20
21import { oneLine } from './data';
22import type { Ports } from './ports';
23
24const STORE_KEY = 'server';
25const OWN_SERVER = 'everythings';
26const CLAUDE_AI_NAME = 'claude.ai Everythings';
27
28/** No Everythings server answered. */
29export class NotConnectedError extends Error {
30 constructor() {
31 super('The Everythings connector is not connected.');
32 this.name = 'NotConnectedError';
33 }
34}
35
36/** Claude Code refused the pane's call to `server`; the message is the engine's own words. */
37export class RefusedError extends Error {
38 constructor(
39 readonly server: string,
40 message: string,
41 ) {
42 super(message);
43 this.name = 'RefusedError';
44 }
45}
46
47/** The engine words a refusal `$.mcp.call(<server>, <tool>) refused: <reason>`. */
48const REFUSED = /\brefused:/;
49
50/** The server the session's reads go to, else the one kept from earlier sessions. */
51export async function learnedServer(p: Ports): Promise<string | null> {
52 const kept = await p.server.read();
53 if (kept) return kept;
54 const stored = await p.storeGet(STORE_KEY);
55 return typeof stored === 'string' && stored ? stored : null;
56}
57
58/** True when `server` is the Everythings server already learned. */
59export async function isLearnedServer(p: Ports, server: string): Promise<boolean> {
60 return (await learnedServer(p)) === server;
61}
62
63/** An observed Everythings call named `server`: reads go there, now and in later sessions. */
64export async function learnServer(p: Ports, server: string): Promise<void> {
65 if ((await p.server.read()) !== server) await p.server.update(() => server);
66 if ((await p.storeGet(STORE_KEY)) !== server) await p.storeSet(STORE_KEY, server);
67}
68
69/** What a rejected `$.mcp.call` says, without the engine's prefix. */
70function reason(error: unknown): string {
71 const text = error instanceof Error ? error.message : String(error);
72 return oneLine(text.replace(/^[\s\S]*?\$\.mcp\.call:\s*/, '')) || 'The Everythings server did not answer.';
73}
74
75/**
76 * One call of a write tool, on the learned server only: the one this
77 * session's reads or calls reached, else the one kept in the store from an
78 * earlier session's calls. No other name is tried and nothing is retried. A refusal throws RefusedError; no learned server throws
79 * NotConnectedError; any other rejection throws its reason.
80 */
81export async function callLearnedServer(
82 p: Ports,
83 tool: string,
84 args: Record<string, unknown>,
85): Promise<McpToolResult> {
86 const server = await learnedServer(p);
87 if (!server) throw new NotConnectedError();
88 try {
89 return await p.mcpCall(server, tool, args);
90 } catch (error) {
91 const text = error instanceof Error ? error.message : String(error);
92 if (REFUSED.test(text)) throw new RefusedError(server, text);
93 throw new Error(reason(error));
94 }
95}
96
97/**
98 * Calls a read tool on the first Everythings server that answers. A refusal
99 * throws RefusedError at once. When no server answers, it throws
100 * NotConnectedError, or the error of the server this session had been
101 * reading from, which says more than "not connected" does.
102 */
103export async function callEverythings(
104 p: Ports,
105 tool: string,
106 args: Record<string, unknown>,
107): Promise<McpToolResult> {
108 const tried = new Set<string>();
109 const session = await p.server.read();
110 let sessionError: string | null = null;
111
112 const attempt = async (server: string | null): Promise<McpToolResult | null> => {
113 if (!server || tried.has(server)) return null;
114 tried.add(server);
115 try {
116 const result = await p.mcpCall(server, tool, args);
117 if (server !== session) await p.server.update(() => server);
118 return result;
119 } catch (error) {
120 const text = error instanceof Error ? error.message : String(error);
121 if (REFUSED.test(text)) throw new RefusedError(server, text);
122 if (server === session) sessionError = reason(error);
123 return null;
124 }
125 };
126
127 const first = await attempt(await learnedServer(p));
128 if (first) return first;
129
130 try {
131 const own = await p.mcpConnect(OWN_SERVER);
132 const answer = own.isConnected ? await attempt(own.server) : null;
133 if (answer) return answer;
134 } catch (error) {
135 // A refusal ends the read; a connect that failed in any other way counts as no answer.
136 if (error instanceof RefusedError) throw error;
137 }
138
139 const viaClaudeAi = await attempt(CLAUDE_AI_NAME);
140 if (viaClaudeAi) return viaClaudeAi;
141
142 if (sessionError !== null) throw new Error(sessionError);
143 throw new NotConnectedError();
144}
145