SLOPSHOPPER

big-picture

When Jev judges the work big, writes a visual HTML summary (show-me style) and keeps it updated in an Orca browser tab (a terminal pane outside Orca) while the…

newpanebandguardcommandtoast
v0.3.3Apache-2.0updated 2026-10-14vampik33/claude-plugins/plugins/big-picture
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · big-picture
│ ┃ Big picture ✕ › fix the failing auth test and add an audit log call │ ┃ No big work in this session yet. This page f │ ┃ Jev judges the work big, and keeps up while ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /big-picture │ ⎿ big-picture: Summary shown. │ │ ⟨Claude Code's own drawing⟩ summary [ hide ] [ update ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ summary [ hide ] [ update ]
Pane · Big picture
No big work in this session yet. This page fills in once Jev judges the work big, and keeps up while it goes on.
README

big-picture

When a piece of work in the session turns out big, a visual HTML summary of it (diagrams, file and call trees, in the style of HumanLayer's show-me skill) appears in an Orca browser tab beside the terminal (outside Orca, as text in a terminal pane), and keeps up while the work goes on.

summary  ● writing…  show  update          a check is running
summary  updated 09:05  hide  update       the page is current; its tab is open
summary  ✗ Jev answered HTTP 401  show  update

Installation

claude plugin install big-picture@vampik-plugins

Set a TypeSafe key for Jev, in ~/.claude/settings.json:

{ "env": { "TYPESAFE_API_KEY": "…" } }

Behaviour

  • /big-picture shows or hides the summary's Orca browser tab; so does the summary show line above the prompt (hotkey s). The line appears once there is something to show: a summary being written, written, or failing; while Jev only judges work that has no summary yet, it stays hidden. Before there is a summary, /big-picture opens a placeholder that fills in later, and the line shows while it is open so it can be hidden. The tab never opens by itself. A press while a show or hide is under way is dropped.
  • /big-picture update, or update on the summary line (hotkey u), rewrites the summary now: no Jev question, even before the work was judged big (which it counts as from then on). A press while a check runs is run once after it; several presses meanwhile, once.
  • A tab closed in Orca by hand turns the button back to show within about 10 seconds (the mod looks for its tab in orca tab list every 2 seconds while one is open; Orca takes about 8 seconds to answer when no browser tab is left).
  • The page is ~/.claude/big-picture/<session-id>.html. Each rewrite reloads the open tab.
  • Outside Orca (TERM_PROGRAM is not Orca) there is no browser: Haiku writes the summary as text instead (headings, short paragraphs, and diagrams and trees drawn in text, no line of a diagram over 60 characters), and /big-picture or the summary line opens and closes it in a terminal pane. Nothing is written to disk.

How It Works

Every tool call that goes through (subagents' too) is logged: files written or edited, commands run, agents started. A check runs every 10 tool calls and when a turn ends; a turn that changed no file is never checked. A progress check runs every 30 minutes, for long-running background shells (step 3).

  1. Is it big? Until the session's work is judged big, Jev (TypeSafe's classifier, POST api.typesafe.ai/v1/systemone) gets one yes/no question over the turn's prompt and log. At p ≥ 0.5 the work is big and the first page is written.
  2. Does it need an update? From then on Jev is asked whether the work since the last page changed the picture; at p ≥ 0.5 the page is rewritten. Work it turns down still counts toward the next question. During a turn, rewrites are at least 5 minutes apart (a check inside the gap asks nothing and keeps the work for later); a turn's end may always rewrite.
  3. Is there progress? Every 30 minutes, once a summary exists, the background shells still running are found in the transcript (a Bash call whose record names the background task it became, with no task notification for it since) and the last 2 KB of each one's tasks/<id>.output file is read. When that output moved since the last look, Jev is asked, over the commands alone, whether their progress is worth a rewrite; at p ≥ 0.5 Haiku rewrites the page with the output. The output never goes to Jev (TypeSafe), only to Haiku. A no is not asked again on the same output; a failed ask or write is. A long foreground command's output cannot be read until it ends.
  4. Writing is Haiku ($.model.complete, 16k tokens at most): show-me's guidance and the page's contract as a cached system prompt, the transcript as a digest (what was typed, said and run: the newest 60k characters), and the previous summary to update, and the commands still running with the end of their output. Mermaid comes from jsDelivr, pinned. The page runs no script of Haiku's: its scripts, <meta> and <link> tags are stripped, and a Content-Security-Policy lets only the mod's Mermaid loader and Mermaid's own files run. The text summary has its escape sequences and control characters stripped.

Each check costs a Jev call; only a yes costs a Haiku call. Needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).

show-me's text is HumanLayer's, MIT: licenses/show-me.LICENSE.

Source 4 files
hooks/register.tsx 600 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { SummaryStatus } from "../types";
5import {
6  BIG_AT,
7  cleanText,
8  emptyActivity,
9  extractHtml,
10  fmtTime,
11  type Format,
12  hasTab,
13  isCheckDue,
14  isRewriteDue,
15  JEV_URL,
16  jevBigRequest,
17  jevProgressRequest,
18  type JevQuestion,
19  jevUpdateRequest,
20  liveShells,
21  logTool,
22  mayBeBig,
23  mergeActivity,
24  type Message,
25  parseJev,
26  parseTabId,
27  placeholderHtml,
28  digest,
29  PROGRESS_EVERY_MS,
30  type Progress,
31  securePage,
32  summaryPrompt,
33  summarySystem,
34  TAIL_BYTES,
35  textBlocks,
36} from "./core.ts";
37import { SHOW_ME } from "./show-me.ts";
38
39const status = atom(
40  { plugin: "big-picture", key: "status" } as const,
41  { kind: "none" } as SummaryStatus,
42);
43const tab = atom(
44  { plugin: "big-picture", key: "tab" } as const,
45  null as string | null,
46);
47const pane = atom({ plugin: "big-picture", key: "pane" } as const, false);
48const text = atom(
49  { plugin: "big-picture", key: "text" } as const,
50  null as string | null,
51);
52
53const ORCA_MS = 10_000;
54/** How often an open tab is looked for, so one closed in Orca flips the button to show. */
55const TAB_WATCH_MS = 2_000;
56const PANE = "big-picture";
57const SUMMARY_MODEL = "haiku";
58const SUMMARY_TOKENS = 16_000;
59const SUMMARY_MS = 180_000;
60const PLACEHOLDER =
61  "No big work in this session yet. This page fills in once Jev judges the work big, and keeps up while it goes on.";
62
63// the session's work; a reload starts it over
64/** The current turn's work: what Jev judges "big" on. */
65let activity = emptyActivity("");
66/** All work since the summary was last written: what Jev judges "update" on. */
67let since = emptyActivity("");
68/** Once Jev judged the work big, later checks ask whether to update instead. */
69let isBig = false;
70/** The summary last written (an HTML page or text), handed to Haiku to update. */
71let lastSummary: string | undefined;
72/** When the page was last written (ms since epoch). */
73let lastWrittenAt: number | undefined;
74/** The running commands' output when the summary was last written or a progress check last looked. */
75let lastProgress: string | undefined;
76let running = false;
77/** What asked for a check: tool calls, a turn's end, the progress timer, or a press. */
78type Why = "tools" | "turn" | "progress" | "manual";
79/** Checks asked for while one ran: they run after it, each kind once. */
80let queued = new Set<Why>();
81let warned = false;
82/** A show or hide under way: presses meanwhile are dropped, not stacked into more tabs. */
83let toggling = false;
84/** Looks at the running shells every PROGRESS_EVERY_MS. */
85let progressWatch: { cancel: () => void } | undefined;
86/** Looks for the open tab every TAB_WATCH_MS. */
87let tabWatch: { cancel: () => void } | undefined;
88/** A look under way (Orca takes ~8 s to list no tabs): a tick meanwhile is skipped. */
89let watching = false;
90
91const setStatus = ($: EngineInterface, s: SummaryStatus) =>
92  update($, status, () => s);
93
94async function pagePath($: EngineInterface): Promise<string> {
95  const home = (await $.env.get("HOME").catch(() => undefined)) ?? "/tmp";
96  return `${home}/.claude/big-picture/${await $.session.id()}.html`;
97}
98
99async function writePage($: EngineInterface, html: string): Promise<string> {
100  const path = await pagePath($);
101  await $.process.run(["mkdir", "-p", path.slice(0, path.lastIndexOf("/"))]);
102  await $.fs.write(path, html);
103  return path;
104}
105
106const orca = ($: EngineInterface, args: string[]) =>
107  $.process
108    .run(["orca", ...args, "--json"], { timeoutMs: ORCA_MS })
109    .catch(() => undefined);
110
111const inOrca = async ($: EngineInterface) =>
112  (await $.env.get("TERM_PROGRAM").catch(() => undefined)) === "Orca";
113
114const messagesOf = async ($: EngineInterface): Promise<Message[]> => {
115  const messages = await $.session.messages().catch(() => []);
116  return Array.isArray(messages) ? messages : [];
117};
118
119/** The background shells still running and the end of each one's output; one whose file is gone is left out. */
120async function readProgress(
121  $: EngineInterface,
122  messages: readonly Message[],
123): Promise<Progress[]> {
124  const progress: Progress[] = [];
125  for (const shell of liveShells(messages)) {
126    const ran = await $.process
127      .run(["tail", "-c", String(TAIL_BYTES), shell.outputFile])
128      .catch(() => undefined);
129    if (ran?.exitCode === 0)
130      progress.push({ command: shell.command, tail: cleanText(ran.stdout) });
131  }
132  return progress;
133}
134
135/** The TypeSafe key Jev needs; without one, a toast the first time. */
136async function jevKey($: EngineInterface): Promise<string | undefined> {
137  const key = await $.env.get("TYPESAFE_API_KEY").catch(() => undefined);
138  if (!key && !warned) $.ui.toast("big-picture: set TYPESAFE_API_KEY for Jev");
139  warned ||= !key;
140  return key || undefined;
141}
142
143/** Jev's p(yes) to one question; undefined (status failed) when it did not answer. */
144async function askJev(
145  $: EngineInterface,
146  key: string,
147  name: JevQuestion,
148  body: unknown,
149): Promise<number | undefined> {
150  const res = await $.http
151    .fetch(JEV_URL, {
152      method: "POST",
153      headers: {
154        Authorization: `Bearer ${key}`,
155        "Content-Type": "application/json",
156        Accept: "application/json",
157      },
158      body: JSON.stringify(body),
159    })
160    .catch(() => undefined);
161  const p = res?.ok ? parseJev(res.text, name) : undefined;
162  if (p === undefined)
163    await setStatus($, {
164      kind: "failed",
165      reason: res ? `Jev answered HTTP ${res.status}` : "Jev was not reached",
166    });
167  return p;
168}
169
170/**
171 * Asks Jev one question with the line on classifying: true on a yes; false on
172 * a no, the line as it was; undefined when Jev did not answer (status failed).
173 */
174async function jevSaysYes(
175  $: EngineInterface,
176  key: string,
177  name: JevQuestion,
178  body: unknown,
179): Promise<boolean | undefined> {
180  const was = await read($, status);
181  await setStatus($, { kind: "working", step: "classifying" });
182  const p = await askJev($, key, name, body);
183  if (p === undefined) return undefined;
184  if (p < BIG_AT) await setStatus($, was);
185  return p >= BIG_AT;
186}
187
188/** Writes the page; work it did not cover is kept for the next check, all of it on a failure. */
189async function summarize($: EngineInterface): Promise<void> {
190  // the transcript read now covers `covered`; work logged while Haiku writes stays in `since`
191  const covered = since;
192  since = emptyActivity("");
193  let isWritten = false;
194  try {
195    isWritten = await writeSummary($);
196  } finally {
197    if (!isWritten) since = mergeActivity(covered, since);
198  }
199}
200
201/**
202 * Haiku writes the summary from the transcript (and updates the last one),
203 * then shows it: in Orca a page for the tab, elsewhere text for the pane.
204 */
205async function writeSummary($: EngineInterface): Promise<boolean> {
206  await setStatus($, { kind: "working", step: "writing" });
207  const format: Format = (await inOrca($)) ? "html" : "text";
208  const messages = await messagesOf($);
209  const progress = await readProgress($, messages);
210  const reply = await $.model.complete({
211    model: SUMMARY_MODEL,
212    system: [{ text: summarySystem(SHOW_ME, format), cache: true }],
213    prompt: summaryPrompt(digest(messages), lastSummary, progress),
214    maxTokens: SUMMARY_TOKENS,
215    timeoutMs: SUMMARY_MS,
216  });
217  if (!reply.isAnswered) {
218    await setStatus($, { kind: "failed", reason: reply.reason });
219    return false;
220  }
221  const summary =
222    format === "html" ? extractHtml(reply.text) : cleanText(reply.text);
223  if (!summary) {
224    await setStatus($, {
225      kind: "failed",
226      reason: `the reply held no ${format === "html" ? "HTML page" : "summary"}`,
227    });
228    return false;
229  }
230  // the transcript may carry injected text: the page runs no script of the model's
231  if (format === "html") await writePage($, securePage(summary));
232  else await update($, text, () => summary);
233  lastSummary = summary;
234  lastProgress = JSON.stringify(progress);
235  lastWrittenAt = await $.clock.now();
236  await setStatus($, { kind: "ready", at: lastWrittenAt });
237  const id = await read($, tab);
238  if (id && (await orca($, ["reload", "--page", id]))?.exitCode !== 0)
239    await forgetTab($); // closed in Orca
240  return true;
241}
242
243/**
244 * Asks Jev whether the work is big until it is, and writes the summary; from
245 * then on asks Jev whether the work since needs the summary updated.
246 */
247async function checkOnce(
248  $: EngineInterface,
249  isTurnEnd: boolean,
250): Promise<void> {
251  // a turn that changed no file neither judges nor rewrites; nor does nothing new
252  if (!mayBeBig(activity)) return;
253  if (lastSummary !== undefined && since.tools === 0) return;
254  // mid-turn, too soon after the last page: the work waits in `since`, Jev unasked
255  if (
256    lastSummary !== undefined &&
257    !isRewriteDue(await $.clock.now(), lastWrittenAt, isTurnEnd)
258  )
259    return;
260  const key = await jevKey($);
261  if (!key) return;
262  // a big session whose first page failed: write it, no question
263  const question: JevQuestion | undefined = !isBig
264    ? "big"
265    : lastSummary === undefined
266      ? undefined
267      : "update";
268  if (
269    question &&
270    !(await jevSaysYes(
271      $,
272      key,
273      question,
274      question === "big"
275        ? jevBigRequest(activity)
276        : jevUpdateRequest(activity, since),
277    ))
278  )
279    return;
280  isBig = true;
281  await summarize($);
282}
283
284/**
285 * With a summary written and background shells running: when their output
286 * moved since the last look, asks Jev whether it changed the picture, and
287 * rewrites the summary on a yes.
288 */
289async function progressOnce($: EngineInterface): Promise<void> {
290  if (lastSummary === undefined) return;
291  const progress = await readProgress($, await messagesOf($));
292  const seen = JSON.stringify(progress);
293  if (progress.length === 0 || seen === lastProgress) return;
294  const key = await jevKey($);
295  if (!key) return;
296  // a no is not asked again on the same output; a failed ask or write is
297  const yes = await jevSaysYes(
298    $,
299    key,
300    "progress",
301    jevProgressRequest(activity, progress),
302  );
303  if (yes === false) lastProgress = seen;
304  if (yes) await summarize($);
305}
306
307/** A press: the summary written now, no question asked; the work counts as big from then on. */
308async function manualOnce($: EngineInterface): Promise<void> {
309  isBig = true;
310  await summarize($);
311}
312
313/** The checks one run of the queue asked for; a press covers all the others. */
314async function checkOnceFor($: EngineInterface, whys: Set<Why>): Promise<void> {
315  if (whys.has("manual")) return manualOnce($);
316  if (whys.has("tools") || whys.has("turn")) await checkOnce($, whys.has("turn"));
317  if (whys.has("progress")) await progressOnce($);
318}
319
320/** One check at a time; checks asked for meanwhile run after it, together. */
321async function check($: EngineInterface, why: Why): Promise<void> {
322  queued.add(why);
323  if (running) return;
324  running = true;
325  try {
326    while (queued.size > 0) {
327      const whys = queued;
328      queued = new Set();
329      await checkOnceFor($, whys);
330    }
331  } catch (err) {
332    await setStatus($, { kind: "failed", reason: String(err) }).catch(() => {});
333  } finally {
334    running = false;
335    queued.clear();
336  }
337}
338
339/** What a show or hide says, and whether it did what was asked. */
340type Said = { text: string; isDone: boolean };
341
342async function forgetTab($: EngineInterface): Promise<void> {
343  tabWatch?.cancel();
344  tabWatch = undefined;
345  await update($, tab, () => null);
346}
347
348/** Forgets the tab once Orca no longer lists it: closed there by hand. */
349async function watchTab($: EngineInterface): Promise<void> {
350  if (watching) return;
351  watching = true;
352  try {
353    const id = await read($, tab);
354    if (!id) return await forgetTab($);
355    const list = await orca($, ["tab", "list"]);
356    if (list?.exitCode === 0 && !hasTab(list.stdout, id)) await forgetTab($);
357  } finally {
358    watching = false;
359  }
360}
361
362function startTabWatch($: EngineInterface): void {
363  tabWatch?.cancel();
364  tabWatch = $.clock.every(TAB_WATCH_MS, () => void watchTab($));
365}
366
367/**
368 * After a reload: a written summary keeps the work big and is the one Haiku
369 * updates (the pane's text, else the page on disk), and an open tab is watched.
370 */
371async function restore($: EngineInterface): Promise<void> {
372  const s = await read($, status);
373  if (s.kind === "ready") {
374    isBig = true;
375    lastWrittenAt = s.at;
376    lastSummary =
377      (await read($, text)) ??
378      (await $.fs.read(await pagePath($)).catch(() => undefined));
379  }
380  if (await read($, tab)) startTabWatch($);
381}
382
383async function showTab($: EngineInterface): Promise<Said> {
384  const path = await pagePath($);
385  if (!(await $.fs.exists(path).catch(() => false)))
386    await writePage($, placeholderHtml(PLACEHOLDER));
387  const ran = await orca($, [
388    "tab",
389    "create",
390    "--url",
391    encodeURI(`file://${path}`),
392  ]);
393  const id = ran?.exitCode === 0 ? parseTabId(ran.stdout) : undefined;
394  if (!id) return { text: "Orca did not open a browser tab.", isDone: false };
395  await update($, tab, () => id);
396  startTabWatch($);
397  return { text: "Summary shown.", isDone: true };
398}
399
400async function hideTab($: EngineInterface, id: string): Promise<Said> {
401  await orca($, ["tab", "close", "--page", id]);
402  await forgetTab($);
403  return { text: "Summary hidden.", isDone: true };
404}
405
406/** Hides the tab if Orca still lists it, shows a new one if not. */
407async function toggleTab($: EngineInterface): Promise<Said> {
408  const id = await read($, tab);
409  if (id) {
410    const list = await orca($, ["tab", "list"]);
411    if (list?.exitCode === 0 && hasTab(list.stdout, id)) return hideTab($, id);
412  }
413  return showTab($);
414}
415
416/** Closes the pane if open, opens it if not. */
417async function togglePane($: EngineInterface): Promise<Said> {
418  if (await read($, pane)) {
419    await $.ui.close({ id: PANE });
420    await update($, pane, () => false);
421    return { text: "Summary hidden.", isDone: true };
422  }
423  const opened = await $.ui.open({ id: PANE, title: "Big picture" });
424  await update($, pane, () => opened.isPlaced);
425  return opened.isPlaced
426    ? { text: "Summary shown.", isDone: true }
427    : { text: "The summary pane is waiting for a wider terminal.", isDone: false };
428}
429
430/** In Orca the tab, elsewhere the pane; one at a time. */
431async function toggle($: EngineInterface): Promise<Said> {
432  if (toggling)
433    return { text: "Still showing or hiding the summary.", isDone: true };
434  toggling = true;
435  try {
436    return (await inOrca($)) ? await toggleTab($) : await togglePane($);
437  } finally {
438    toggling = false;
439  }
440}
441
442export const register: Register = (on) => {
443  on("session.start", async ($, e, next) => {
444    const r = await next(e);
445    await $.command.register({
446      name: "big-picture",
447      description:
448        "Show or hide the visual summary of this session's big work; update rewrites it now",
449      argumentHint: "[update]",
450    });
451    progressWatch?.cancel();
452    progressWatch = $.clock.every(
453      PROGRESS_EVERY_MS,
454      () => void check($, "progress"),
455    );
456    await restore($);
457    // a reload keeps an open pane
458    const panes = await $.ui.panes().catch(() => []);
459    await update($, pane, () => panes.some((p) => p.id === PANE && p.isPlaced));
460    return r;
461  });
462
463  // a /clear: the new session starts with no work and no summary; the old page's tab
464  // is closed (Orca can be slow, so on a timer, outside session.end's short bound)
465  on("session.end", async ($, e, next) => {
466    const r = await next(e);
467    if (e.reason !== "clear") return r;
468    activity = emptyActivity("");
469    since = emptyActivity("");
470    isBig = false;
471    lastSummary = undefined;
472    lastWrittenAt = undefined;
473    lastProgress = undefined;
474    await setStatus($, { kind: "none" });
475    await update($, text, () => null);
476    const id = await read($, tab);
477    await forgetTab($);
478    if (id)
479      $.clock.after(0, () => void orca($, ["tab", "close", "--page", id]));
480    return r;
481  });
482
483  on("command.run", { command: "big-picture" }, async ($, e) => {
484    if (e.args.trim() !== "update") return { text: (await toggle($)).text };
485    $.clock.after(0, () => void check($, "manual"));
486    return { text: "Updating the summary." };
487  });
488
489  on("prompt.submit", async ($, e, next) => {
490    activity = emptyActivity(e.text);
491    return next(e);
492  });
493
494  // logging never fails the tool; checks run on a timer, outside this dispatch
495  on("tool.call", async ($, e, next) => {
496    const ran = await next(e);
497    if (ran.deny === undefined && !ran.isError) {
498      const input = e as unknown as Record<string, unknown>;
499      activity = logTool(activity, e.tool, input);
500      since = logTool(since, e.tool, input);
501      if (isCheckDue(activity))
502        $.clock.after(0, () => void check($, "tools"));
503    }
504    return ran;
505  });
506
507  on("turn.complete", async ($, e, next) => {
508    const r = await next(e);
509    if (e.agentId === undefined && !e.isAborted)
510      $.clock.after(0, () => void check($, "turn"));
511    return r;
512  });
513
514  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
515    const { Box, Text } = $.ui.resolve(e);
516    const summary = await read($, text);
517    if (summary === null)
518      return (
519        <Box width={e.props.bodyColumns}>
520          <Text dimColor>{PLACEHOLDER}</Text>
521        </Box>
522      );
523    return (
524      <Box flexDirection="column" width={e.props.bodyColumns}>
525        {textBlocks(summary).map((b, i) => (
526          <Box key={`block:${i}`} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
527            {b.kind === "heading" && <Text bold>{b.lines[0]}</Text>}
528            {b.kind === "prose" && <Text>{b.lines[0]}</Text>}
529            {b.kind === "sketch" &&
530              b.lines.map((line, j) => (
531                <Text key={`line:${j}`} wrap="truncate-end">
532                  {line === "" ? " " : line}
533                </Text>
534              ))}
535          </Box>
536        ))}
537      </Box>
538    );
539  });
540
541  on("ui.close", { id: PANE }, async ($, e, next) => {
542    const r = await next(e);
543    await update($, pane, () => false);
544    return r;
545  });
546
547  // its own line under the other bands: the summary's state and a show/hide toggle,
548  // once there is something to show (or the summary is open, so it can be hidden);
549  // Jev judging work that has no summary yet is not something to show
550  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
551    if (e.props.hasSurvey) return next(e);
552    const s = await read($, status);
553    const open = (await read($, tab)) !== null || (await read($, pane));
554    const isJudgingOnly =
555      s.kind === "working" && s.step === "classifying" && lastSummary === undefined;
556    if (!open && (s.kind === "none" || isJudgingOnly)) return next(e);
557    const below = await next(e);
558    const { Box, Button, Text } = $.ui.resolve(e);
559    return (
560      <Box flexDirection="column">
561        {below}
562        <Box
563          flexDirection="row"
564          columnGap={2}
565          marginTop={1}
566          width={e.props.bodyColumns}
567        >
568          <Text dimColor>summary</Text>
569          {s.kind === "working" && <Text color="yellow">{`● ${s.step}…`}</Text>}
570          {s.kind === "ready" && (
571            <Text dimColor>{`updated ${fmtTime(s.at)}`}</Text>
572          )}
573          {s.kind === "failed" && (
574            <Text color="red" wrap="truncate-end">{`✗ ${s.reason}`}</Text>
575          )}
576          <Button
577            key="big-picture-toggle"
578            hotkey="s"
579            dimColor
580            onPress={async () => {
581              const said = await toggle($);
582              if (!said.isDone) $.ui.toast(`big-picture: ${said.text}`);
583            }}
584          >
585            {open ? "hide" : "show"}
586          </Button>
587          <Button
588            key="big-picture-update"
589            hotkey="u"
590            dimColor
591            onPress={() => void check($, "manual")}
592          >
593            update
594          </Button>
595        </Box>
596      </Box>
597    );
598  });
599};
600
hooks/core.ts 468 lines
1/** A check runs after this many tool calls, and once more at the turn's end. */
2export const CHECK_EVERY = 10;
3/** During a turn, rewrites are at least this far apart; a turn's end may always rewrite. */
4export const MIN_GAP_MS = 5 * 60_000;
5/** How often the background shells still running are looked at, for progress to show. */
6export const PROGRESS_EVERY_MS = 30 * 60_000;
7/** How much of a running shell's output, from its end, a progress check reads. */
8export const TAIL_BYTES = 2_000;
9/** Jev's p(yes) at or above which the work counts as big. */
10export const BIG_AT = 0.5;
11export const JEV_URL = "https://api.typesafe.ai/v1/systemone";
12export const MERMAID_URL =
13  "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs";
14
15const MAX_COMMANDS = 30;
16const MAX_TEXT = 200;
17
18/** What the current turn has done, as Jev reads it. */
19export type Activity = {
20  prompt: string;
21  /** Tool calls that went through, subagents' included. */
22  tools: number;
23  /** Files written or edited, each once, in order. */
24  edits: string[];
25  /** Shell commands run, the first MAX_COMMANDS, each cut short. */
26  commands: string[];
27  /** Subagents started, by description. */
28  agents: string[];
29};
30
31export function emptyActivity(prompt: string): Activity {
32  return { prompt, tools: 0, edits: [], commands: [], agents: [] };
33}
34
35const cut = (s: string) =>
36  s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT - 1)}…` : s;
37
38const EDITING = new Set(["Write", "Edit", "NotebookEdit"]);
39
40/** The activity with one more tool call that went through. */
41export function logTool(
42  a: Activity,
43  tool: string,
44  input: Readonly<Record<string, unknown>>,
45): Activity {
46  const next = { ...a, tools: a.tools + 1 };
47  const path = input.file_path ?? input.notebook_path;
48  if (EDITING.has(tool) && typeof path === "string" && !a.edits.includes(path))
49    next.edits = [...a.edits, path];
50  if (
51    tool === "Bash" &&
52    typeof input.command === "string" &&
53    a.commands.length < MAX_COMMANDS
54  )
55    next.commands = [...a.commands, cut(input.command)];
56  if (tool === "Agent" && typeof input.description === "string")
57    next.agents = [...a.agents, cut(input.description)];
58  return next;
59}
60
61/** `a`, then `b` logged after it, as one activity. */
62export function mergeActivity(a: Activity, b: Activity): Activity {
63  return {
64    prompt: b.prompt || a.prompt,
65    tools: a.tools + b.tools,
66    edits: [...a.edits, ...b.edits.filter((e) => !a.edits.includes(e))],
67    commands: [...a.commands, ...b.commands].slice(0, MAX_COMMANDS),
68    agents: [...a.agents, ...b.agents],
69  };
70}
71
72/** A check is due at every CHECK_EVERY-th tool call. */
73export const isCheckDue = (a: Activity) =>
74  a.tools > 0 && a.tools % CHECK_EVERY === 0;
75
76/** Whether a check may lead to a rewrite: always at a turn's end, else MIN_GAP_MS after the last. */
77export const isRewriteDue = (
78  now: number,
79  lastWrittenAt: number | undefined,
80  isTurnEnd: boolean,
81) =>
82  isTurnEnd || lastWrittenAt === undefined || now - lastWrittenAt >= MIN_GAP_MS;
83
84/** Work that changed no file is never big: Jev is not asked. */
85export const mayBeBig = (a: Activity) => a.edits.length > 0;
86
87const workDone = (a: Activity) => ({
88  toolCalls: a.tools,
89  filesChanged: a.edits,
90  commandsRun: a.commands,
91  subagents: a.agents,
92});
93
94const jevBody = (
95  name: JevQuestion,
96  state: unknown,
97  instructions: string,
98  criteria: { true: string; false: string },
99) => ({
100  model: "jev-latest",
101  state,
102  questions: { [name]: { type: "noul", instructions, criteria } },
103});
104
105export type JevQuestion = "big" | "update" | "progress";
106
107/** Jev's yes/no: is the turn's work big enough for a visual summary? */
108export function jevBigRequest(turn: Activity) {
109  return jevBody(
110    "big",
111    { request: cut(turn.prompt), ...workDone(turn) },
112    "Is this a big piece of coding work, worth a visual summary with diagrams for the person who asked for it?",
113    {
114      true: "a feature, a refactor or a fix spanning several files or steps; a reviewer would want the big picture",
115      false:
116        "a small tweak, a one-file fix, a question answered, or mostly reading and searching",
117    },
118  );
119}
120
121/** Jev's yes/no: has the work since the last summary changed its picture? */
122export function jevUpdateRequest(turn: Activity, since: Activity) {
123  return jevBody(
124    "update",
125    { request: cut(turn.prompt), workSinceTheSummary: workDone(since) },
126    "A visual summary of this session's work was written earlier. Has the work done since changed the picture enough that the summary needs rewriting?",
127    {
128      true: "new files, components, flows or decisions the summary cannot show, or a step finished that it shows as open",
129      false:
130        "small follow-ups, test runs, formatting or fixes inside what the summary already shows",
131    },
132  );
133}
134
135/**
136 * Jev's yes/no: is new output from these running commands worth a rewrite?
137 * Jev is a third party: it gets the commands, never their output (which may
138 * hold secrets); only Haiku reads that.
139 */
140export function jevProgressRequest(turn: Activity, running: readonly Progress[]) {
141  return jevBody(
142    "progress",
143    {
144      request: cut(turn.prompt),
145      runningCommands: running.map((r) => cut(r.command)),
146    },
147    "A visual summary of this session's work was written earlier, while these long-running commands kept running. They have printed new output since. Is their progress likely worth rewriting the summary for?",
148    {
149      true: "builds, test suites, benchmarks, migrations, deploys or other long jobs whose progress or result is part of the work's picture",
150      false:
151        "servers, watchers, log tails or other commands that run on without a result to show",
152    },
153  );
154}
155
156/** p(yes) of the named question from Jev's reply, or undefined when it is not one. */
157export function parseJev(text: string, name: JevQuestion): number | undefined {
158  try {
159    const answers = (
160      JSON.parse(text) as { answers?: Record<string, { noul?: unknown }> }
161    ).answers;
162    const p = answers?.[name]?.noul;
163    return typeof p === "number" && p >= 0 && p <= 1 ? p : undefined;
164  } catch {
165    return undefined;
166  }
167}
168
169/** One transcript row, as `$.session.messages()` gives it. */
170export type Message = {
171  role: "user" | "assistant";
172  text: string;
173  toolUses: readonly {
174    tool: string;
175    input: Record<string, unknown>;
176    /** The result as the model read it, once answered. */
177    text?: string;
178    /** The tool's record, once answered (a Bash one's `backgroundTaskId`). */
179    result?: unknown;
180  }[];
181  toolResults?: readonly unknown[];
182};
183
184const DIGEST_CHARS = 60_000;
185
186/** One tool use in a line: the tool and its file, command, description or pattern. */
187export function toolLine(tool: string, input: Record<string, unknown>): string {
188  const what = [
189    input.file_path,
190    input.command,
191    input.description,
192    input.pattern,
193  ].find((v) => typeof v === "string") as string | undefined;
194  return what ? `${tool} ${cut(what.split("\n")[0]!)}` : tool;
195}
196
197/**
198 * The transcript in lines a small model reads: what the person typed, what
199 * Claude said, each tool used; the newest that fit in `max` characters.
200 */
201export function digest(
202  messages: readonly Message[],
203  max = DIGEST_CHARS,
204): string {
205  const lines: string[] = [];
206  for (const m of messages) {
207    const text = m.text.trim();
208    if (m.role === "user") {
209      if (m.toolResults?.length || text === "" || text.startsWith("<"))
210        continue;
211      lines.push(`USER: ${text}`);
212    } else {
213      if (text !== "") lines.push(`CLAUDE: ${text}`);
214      for (const u of m.toolUses)
215        lines.push(`  → ${toolLine(u.tool, u.input)}`);
216    }
217  }
218  const kept: string[] = [];
219  let size = 0;
220  for (let i = lines.length - 1; i >= 0; i--) {
221    size += lines[i]!.length + 1;
222    if (size > max) break;
223    kept.unshift(lines[i]!);
224  }
225  return kept.join("\n");
226}
227
228/** A background shell the transcript shows started and not yet ended. */
229export type LiveShell = { id: string; command: string; outputFile: string };
230
231/**
232 * The background shells still running: a Bash call whose record names the
233 * task it moved to the background, with no task notification for that id
234 * after it. The id is the record's, never the result text's: a command's
235 * output may say anything, so the text only gives the file, and only one
236 * named for that id in a `tasks` directory.
237 */
238export function liveShells(messages: readonly Message[]): LiveShell[] {
239  const live = new Map<string, LiveShell>();
240  for (const m of messages) {
241    if (m.role === "user") {
242      if (!m.text.includes("<task-notification>")) continue;
243      for (const [, id] of m.text.matchAll(/<task-id>([^<]+)<\/task-id>/g))
244        live.delete(id!.trim());
245      continue;
246    }
247    for (const u of m.toolUses) {
248      if (u.tool !== "Bash" || typeof u.text !== "string") continue;
249      const id = (u.result as { backgroundTaskId?: unknown } | undefined)
250        ?.backgroundTaskId;
251      if (typeof id !== "string" || !/^[\w-]+$/.test(id)) continue;
252      const outputFile = new RegExp(
253        `Output is being written to: (/\\S*/tasks/${id}\\.output)`,
254      ).exec(u.text)?.[1];
255      if (outputFile && !outputFile.split("/").includes(".."))
256        live.set(id, {
257          id,
258          command: typeof u.input.command === "string" ? u.input.command : "",
259          outputFile,
260        });
261    }
262  }
263  return [...live.values()];
264}
265
266/** A running command and the end of its output, as a summary shows its progress. */
267export type Progress = { command: string; tail: string };
268
269/** Where the summary is shown: an HTML page in an Orca tab, or text in a terminal pane. */
270export type Format = "html" | "text";
271
272/** The text summary's widest line, so the pane need not wrap a diagram. */
273export const TEXT_WIDTH = 60;
274
275const HTML_CONTRACT = `The summary is one self-contained HTML page:
276- Text sketches (pseudocode, call trees, file trees, diffs) go in <pre> blocks; color diff lines.
277- Mermaid diagrams go in <pre class="mermaid"> blocks; the page gets its Mermaid script added, so write no <script> at all.
278- No external scripts, styles, fonts or images. Inline CSS, light and dark via prefers-color-scheme, readable in a narrow side panel.
279- Open with a title and one short paragraph; real file names, functions and labels from the session. Claim nothing the session does not show.
280- It is a diagram summary: at least one Mermaid diagram of how the pieces connect or flow, and a text sketch (a file tree or call tree) of what changed. Prose only labels the visuals.
281
282Reply with the HTML document alone, starting with <!doctype html>: no prose, no code fence.`;
283
284const TEXT_CONTRACT = `The summary is plain text for a terminal side pane, no line of a diagram wider than ${TEXT_WIDTH} characters:
285- A line starting with "# " is a section heading; the first line is "# " and the title.
286- Diagrams and sketches (file trees, call trees, pseudocode, diffs, and flows drawn with box-drawing characters like ┌─┐│└┘├▶→ where show-me would use Mermaid) go between two lines of three backticks alone, shown as written.
287- Prose is short paragraphs, a blank line between them; it wraps to the pane.
288- Nothing else: no Markdown emphasis, links or tables, no HTML, no escape codes.
289- Real file names, functions and labels from the session. Claim nothing the session does not show.
290- It is a diagram summary: at least one diagram of how the pieces connect or flow, and a file tree or call tree of what changed. Prose only labels the visuals.
291
292Reply with the summary alone: no preamble, no fence around all of it.`;
293
294/** The system prompt: show-me's guidance, then the format's contract. Fixed, so cached. */
295export function summarySystem(showMe: string, format: Format): string {
296  return `You write a visual summary of a person's coding session with Claude: what was done and why, how the pieces fit, what is still in progress or next. Follow this guidance for choosing the views:
297
298<show-me>
299${showMe}
300</show-me>
301
302${format === "html" ? HTML_CONTRACT : TEXT_CONTRACT}`;
303}
304
305const runningSection = (running: readonly Progress[]) =>
306  running.length === 0
307    ? ""
308    : `\n\n<running-commands>\nThese commands are still running; show where they stand.\n${running
309        .map((r) => `$ ${cut(r.command)}\n${r.tail}`)
310        .join("\n\n")}\n</running-commands>`;
311
312/** The user message: the session, the commands still running, and the page to update when there is one. */
313export function summaryPrompt(
314  session: string,
315  previous?: string,
316  running: readonly Progress[] = [],
317): string {
318  const now = `<session>\n${session}\n</session>${runningSection(running)}`;
319  return previous === undefined
320    ? `${now}\n\nWrite the summary of this session's work.`
321    : `${now}\n\n<previous-summary>\n${previous}\n</previous-summary>\n\nUpdate the previous summary to the session as it stands: keep what still holds, its layout and diagrams where they still fit; add, change or drop what the newer work changed.`;
322}
323
324/** The HTML document in the model's reply, fences and prose around it dropped. */
325export function extractHtml(text: string): string | undefined {
326  const start = text.search(/<!doctype html|<html[\s>]/i);
327  const end = text.toLowerCase().lastIndexOf("</html>");
328  if (start < 0 || end < start) return undefined;
329  return text.slice(start, end + "</html>".length);
330}
331
332/** The one script a summary runs: Mermaid, from MERMAID_URL. */
333export const MERMAID_LOADER = `<script type="module">import mermaid from "${MERMAID_URL}"; mermaid.initialize({ startOnLoad: true, theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "default" });</script>`;
334/** sha256 of MERMAID_LOADER's body, base64: the CSP lets it run and no other inline script. */
335export const MERMAID_LOADER_SHA256 =
336  "Olg3I0JmxL6iPaXBstF8PxlFzHJ/YejN9UP70TigNEw=";
337
338/**
339 * Only the Mermaid loader and Mermaid's own files may run: no other script,
340 * no request out but Mermaid's, no base, form or frame. Allows inline styles
341 * and data: images, which Mermaid draws with.
342 */
343export const SUMMARY_CSP = [
344  "default-src 'none'",
345  `script-src 'sha256-${MERMAID_LOADER_SHA256}' ${MERMAID_URL.slice(0, MERMAID_URL.lastIndexOf("/dist/") + 1)}`,
346  "style-src 'unsafe-inline'",
347  "img-src data:",
348  "base-uri 'none'",
349  "form-action 'none'",
350  "frame-src 'none'",
351].join("; ");
352
353/**
354 * A model-written page made safe to open: its scripts, every <meta> (an
355 * http-equiv may be entity-encoded past any filter) and every <link> (a
356 * dns-prefetch or preconnect reaches out past the CSP) dropped, and, before it, the
357 * charset, the CSP and the Mermaid loader. The CSP comes first, so it
358 * holds for all of the page; a later policy can only narrow it.
359 */
360export function securePage(html: string): string {
361  // until nothing changes: a cut may join `<scr` and `ipt>` into a new tag
362  let body = html.replace(/^\s*<!doctype[^>]*>/i, "");
363  for (let was = ""; was !== body; ) {
364    was = body;
365    body = body
366      .replace(/<script\b[\s\S]*?(<\/script\s*>|$)/gi, "")
367      .replace(/<(meta|link)\b[^>]*>?/gi, "");
368  }
369  return `<!doctype html>
370<meta charset="utf-8">
371<meta http-equiv="Content-Security-Policy" content="${SUMMARY_CSP}">
372${MERMAID_LOADER}
373${body}`;
374}
375
376/** One piece of the text summary as the pane draws it. */
377export type TextBlock = {
378  kind: "heading" | "prose" | "sketch";
379  /** A sketch's lines, as written; a heading's or a paragraph's one line. */
380  lines: string[];
381};
382
383/** The model's text with escape sequences and other control characters gone. */
384export function cleanText(text: string): string {
385  return text
386    .replace(/\r\n?/g, "\n")
387    .replace(/\t/g, "  ")
388    .replace(/\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)?/g, "")
389    .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, "")
390    .trim();
391}
392
393/** The text summary in headings, paragraphs and sketches; an unclosed sketch runs to the end. */
394export function textBlocks(text: string): TextBlock[] {
395  const blocks: TextBlock[] = [];
396  let prose: string[] = [];
397  let sketch: string[] | undefined;
398  const flush = () => {
399    if (prose.length) blocks.push({ kind: "prose", lines: [prose.join(" ")] });
400    prose = [];
401  };
402  for (const line of cleanText(text).split("\n")) {
403    if (/^\s*```/.test(line)) {
404      if (sketch) {
405        if (sketch.length) blocks.push({ kind: "sketch", lines: sketch });
406        sketch = undefined;
407      } else {
408        flush();
409        sketch = [];
410      }
411    } else if (sketch) sketch.push(line);
412    else if (/^#+\s+\S/.test(line)) {
413      flush();
414      blocks.push({ kind: "heading", lines: [line.replace(/^#+\s+/, "")] });
415    } else if (line.trim() === "") flush();
416    else prose.push(line.trim());
417  }
418  flush();
419  if (sketch?.length) blocks.push({ kind: "sketch", lines: sketch });
420  return blocks;
421}
422
423const escape = (s: string) =>
424  s.replace(
425    /[&<>"]/g,
426    (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!,
427  );
428
429/** The page shown before a summary exists. */
430export function placeholderHtml(text: string): string {
431  return `<!doctype html>
432<html><head><meta charset="utf-8"><title>Big picture</title>
433<style>:root{color-scheme:light dark}body{font:15px/1.5 system-ui,sans-serif;margin:2rem;opacity:.7}</style>
434</head><body><p>${escape(text)}</p></body></html>`;
435}
436
437/** `browserPageId` from `orca tab create --json`. */
438export function parseTabId(stdout: string): string | undefined {
439  try {
440    const id = (JSON.parse(stdout) as { result?: { browserPageId?: unknown } })
441      .result?.browserPageId;
442    return typeof id === "string" && id !== "" ? id : undefined;
443  } catch {
444    return undefined;
445  }
446}
447
448/** Whether `orca tab list --json` still lists the page. */
449export function hasTab(stdout: string, id: string): boolean {
450  try {
451    const tabs = (
452      JSON.parse(stdout) as {
453        result?: { tabs?: { browserPageId?: unknown }[] };
454      }
455    ).result?.tabs;
456    return Array.isArray(tabs) && tabs.some((t) => t.browserPageId === id);
457  } catch {
458    return false;
459  }
460}
461
462/** `09:05`, local time. */
463export function fmtTime(at: number): string {
464  const d = new Date(at);
465  const pad = (n: number) => String(n).padStart(2, "0");
466  return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
467}
468
hooks/show-me.ts 125 lines
1/**
2 * HumanLayer's show-me skill, verbatim but for its closing "open it" step
3 * (the mod writes and opens the page). MIT, Copyright (c) 2026 HumanLayer:
4 * https://github.com/humanlayer/skills/tree/main/plugins/show-me, licence in
5 * ../licenses/show-me.LICENSE.
6 */
7export const SHOW_ME = `Help the user understand the current topic of conversation visually. Skip the preamble and keep prose brief. Pick the smallest view that makes the key point clear.
8
9- Show logic or an algorithm as pseudocode:
10
11\`\`\`text
12on(save)
13  if content is unchanged
14    return cached result
15  write new content
16  return fresh result
17\`\`\`
18
19- Show runtime control flow as a call tree:
20
21\`\`\`text
22submitForm
23  createSession
24    persistPrompt
25    launchAgent
26  navigateToSession
27\`\`\`
28
29- Show UI structure as a component tree, including state and module boundaries that matter:
30
31\`\`\`tsx
32<SessionPage> (apps/example/src/routes/session.tsx)
33  useSessionEvents()
34  <SessionToolbar>
35    <RunSkillButton> (packages/ui)
36\`\`\`
37
38- Show file responsibility or a broad refactor as a shallow file tree:
39
40\`\`\`text
41src/
42├── commands/       # parses user actions
43├── sessions/       # owns session state
44└── transport/      # sends API requests
45\`\`\`
46
47- Show component interaction, control flow, or data flow with Mermaid:
48
49\`\`\`mermaid
50sequenceDiagram
51    participant User
52    participant UI
53    participant Daemon
54    User->>UI: choose command
55    UI->>Daemon: send expanded prompt
56    Daemon-->>UI: stream result
57\`\`\`
58
59- Use \`diff\` when the point is what changes and the surrounding shape already exists. Match the diff shape to the topic.
60
61For a component change:
62
63\`\`\`diff
64 <SessionPage>
65   useSessionEvents()
66   <SessionToolbar>
67+    <RunSkillButton />
68   <SessionTimeline>
69+    <SkillResultCard />
70\`\`\`
71
72For a file-layout change:
73
74\`\`\`diff
75 src/
76 ├── commands/
77+│   └── show-me.ts       # expands the slash command
78 ├── sessions/
79-└── transport.ts
80+└── transport/
81+    ├── client.ts
82+    └── stream.ts
83\`\`\`
84
85For a call-tree or call-stack change:
86
87\`\`\`diff
88 submitForm
89   createSession
90     persistPrompt
91+    expandSkillMention
92     launchAgent
93-  navigateToSession
94+  navigateToSession
95+    subscribeToEvents
96\`\`\`
97
98For a state or control-flow change:
99
100\`\`\`diff
101 on(save)
102-  write content
103+  if content is unchanged
104+    return cached result
105+  write new content
106+  invalidate cache
107\`\`\`
108
109- Show the whole block when most of it is new, when omitted context would hide ownership or order, or when the user needs a copyable target shape:
110
111\`\`\`ts
112function expandSkill(command: string): string {
113  const skillName = command.slice(1)
114  return \`use the \${skillName} skill\`
115}
116\`\`\`
117
118- For a visual UI, layout, state comparison, or concept too dense for Mermaid, write one focused HTML file — a diagram, an infographic, or a short slide deck, whichever fits the point. Match the product's colors, type, spacing, and components; use real labels and data; support desktop and mobile.
119
120### guidance
121
122Place each visual next to the short text it supports. Keep only the calls, files, props, states, and boundaries needed to answer the user's current question or the options to resolve the current discussion point.
123
124You may use one of these, you may use several, it is unlikely you will use all of them. Use your judgement and don't overwhelm the user.`;
125
types/index.d.ts 23 lines
1/** Where the summary stands: what the band line shows. */
2export type SummaryStatus =
3  | { kind: "none" }
4  /** Jev or the fork is at work. */
5  | { kind: "working"; step: "classifying" | "writing" }
6  /** The page was last written at `at` (ms since epoch). */
7  | { kind: "ready"; at: number }
8  | { kind: "failed"; reason: string };
9
10declare module "claude-code" {
11  interface PluginState {
12    "big-picture": {
13      status: SummaryStatus;
14      /** The Orca browser page showing the summary, while its tab is open. */
15      tab: string | null;
16      /** Outside Orca: whether the summary's terminal pane is open. */
17      pane: boolean;
18      /** Outside Orca: the text summary the pane shows, once written. */
19      text: string | null;
20    };
21  }
22}
23