SLOPSHOPPER

everythings

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…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · everythings
│ ┃ Everythings ✕ › fix the failing auth test and add an audit log call │ ┃ [ ⋮⋮⋮ ] [ Follow: paused ] [ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Type what to find after the command: ⎿ Read 6 lines │ ┃ /findthing <query> ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /things │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Everythings
[ ⋮⋮⋮ ] [ Follow: paused ] [ Refresh ] Type what to find after the command: /findthing <query>
README

Everythings for Claude Code

Everythings is a collaborative workspace app for notes, lists, and items ("things"). This plugin gives Claude Code three powers:

  • MCP tools in every project — Claude can search, read, and (with a write-scoped sign-in) create and edit your things from any session. When it needs a decision while you are away, it can ask: the question reaches your phone through the Everythings app, and Claude picks up your answer on its next run.
  • /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.

Install

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.)

Setup

  1. An account at everythings.app (free tier works).
  2. On first MCP use, Claude Code walks you through the OAuth sign-in to https://www.everythings.app/api/mcp.
  3. For the panel: in claude.ai Settings → Connectors, find Everythings in the connector directory and connect it (or add the same URL as a custom connector; keep the name Everythings), then run /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.

The pane

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.

Sign-in, credentials, and data

  • The plugin holds no credentials and never asks you for one. It reads no environment variables, no files, and no tokens from your machine, and it has no shell scripts or local servers.
  • The /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.
  • Sign-in is OAuth 2.1 with PKCE against 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.
  • Your workspaces, things, marks, and comments travel only between Claude and 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.
  • Privacy policy: everythings.app/privacy. Terms: everythings.app/terms.

Using Cowork?

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.

Notes

  • Under auto permission mode Claude Code can refuse the pane's own reads and writes. The pane then draws from what the agent's calls already carried and shows a note with the exact mcp__<server>__<tool> allow rules to add to your permission settings, with a Copy button.
  • A mark or comment you make from the pane is yours, and it carries the agent's label, because the call rides the agent's sign-in.
  • The panel is read-only and refreshes on a ~30 second poll (the connector platform's floor). It shows which things an agent wrote and any question it is waiting on you for, but never answers one: answer in the app, or tell the agent in the chat.
  • Panel improvements ship with plugin updates; running /everythings:panel after an update republishes the new page to your same URL.

License

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.

Source 15 files
hooks/register.tsx 372 lines
1// 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}
372
hooks/ask.ts 92 lines
1// 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}
92
hooks/cache.ts 734 lines
1// 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}
734
hooks/data.ts 766 lines
1// 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}
766
hooks/follow.ts 67 lines
1// 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}
67
hooks/inbox.ts 303 lines
1// 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}
303
hooks/nav.ts 580 lines
1// 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}
580
hooks/pane.tsx 813 lines
1// 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}
813
hooks/ports.ts 91 lines
1// 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};
91
hooks/rows.tsx 170 lines
1// 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}
170
hooks/showThing.ts 78 lines
1// 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}
78
hooks/server.ts 145 lines
1// 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