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…

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
claude plugin install big-picture@vampik-plugins
Set a TypeSafe key for Jev, in ~/.claude/settings.json:
{ "env": { "TYPESAFE_API_KEY": "…" } }
/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.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).~/.claude/big-picture/<session-id>.html. Each rewrite reloads the open tab.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.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).
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.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.$.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.
hooks/register.tsx 600 lines1import { 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};
600hooks/core.ts 468 lines1/** 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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}
468hooks/show-me.ts 125 lines1/**
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.`;
125types/index.d.ts 23 lines1/** 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