A Claude Code pane that lists what Sphica's hooks handed the agent in this session. Reads hook results only; writes nothing.

A Claude Code pane that lists what Sphica's hooks handed the agent in this session: the current work and constraints at session start, the records shown before a read, an edit, or a prompt, and the reason when Sphica stopped a shell call.
Install it beside Sphica from the same marketplace, then run /sphica-pane:
claude plugin install sphica-pane@sphica
claude plugin uninstall sphica-pane@sphica. Sphica itself keeps workinghooks/register.tsx 82 lines1// Copyright (c) 2026 iroha924 and contributors
2// SPDX-License-Identifier: MIT
3
4// Reads what the hooks beneath handed the agent and lists Sphica's part in a pane. Every hook returns the result beneath unchanged,
5// and its .catch replays that result: next() never runs Sphica's hooks twice, so a failure here cannot repeat a delivery or a stop.
6import { atom, read, update } from "claude-code";
7import type { EngineInterface, Register } from "claude-code";
8import { heading, LIMITS, layout, omitted, type Result, sift } from "./sift.ts";
9
10const PANE = "sphica-delivered";
11const delivered = atom({ plugin: "sphica-pane", key: "delivered" } as const, []);
12
13async function keep($: EngineInterface, event: string, result: Result): Promise<void> {
14 const rows = sift(result, event, new Date().toTimeString().slice(0, 8));
15 if (rows.length > 0) await update($, delivered, (list) => [...list, ...rows].slice(-LIMITS.kept));
16}
17
18export const register: Register = (on) => {
19 on("session.start", async ($, e, next) => {
20 await $.command.register({
21 name: "sphica-pane",
22 description: "Show what Sphica's hooks handed the agent in this session",
23 });
24 return next(e);
25 });
26
27 on("command.run", { command: "sphica-pane" }, async ($) => {
28 await $.ui.open({ id: PANE, title: "Sphica" });
29 return { text: "Sphica pane opened. Close it with ctrl+x x." };
30 });
31
32 on("classic.SessionStart", async ($, e, next) => {
33 const result = await next(e);
34 await keep($, "SessionStart", result).catch(() => {});
35 return result;
36 }).catch(($, e, replay) => replay(e));
37
38 on("classic.SubagentStart", async ($, e, next) => {
39 const result = await next(e);
40 await keep($, "SubagentStart", result).catch(() => {});
41 return result;
42 }).catch(($, e, replay) => replay(e));
43
44 on("classic.UserPromptSubmit", async ($, e, next) => {
45 const result = await next(e);
46 await keep($, "UserPromptSubmit", result).catch(() => {});
47 return result;
48 }).catch(($, e, replay) => replay(e));
49
50 on("classic.UserPromptExpansion", async ($, e, next) => {
51 const result = await next(e);
52 await keep($, "UserPromptExpansion", result).catch(() => {});
53 return result;
54 }).catch(($, e, replay) => replay(e));
55
56 on("classic.PreToolUse", async ($, e, next) => {
57 const result = await next(e);
58 await keep($, "PreToolUse", result).catch(() => {});
59 return result;
60 }).catch(($, e, replay) => replay(e));
61
62 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
63 const { Box, Text } = $.ui.resolve(e);
64 const { shown, cut, hidden } = layout(await read($, delivered));
65 const note = omitted(cut, hidden);
66 return (
67 <Box flexDirection="column">
68 {shown.length === 0 && <Text dimColor>Sphica has handed nothing over in this session yet.</Text>}
69 {shown.map((row) => (
70 <Box flexDirection="column">
71 <Text bold color={row.kind === "stop" ? "yellow" : undefined}>
72 {heading(row)}
73 </Text>
74 <Text>{row.text}</Text>
75 </Box>
76 ))}
77 {note && <Text dimColor>{note}</Text>}
78 </Box>
79 );
80 });
81};
82hooks/sift.ts 56 lines1// Copyright (c) 2026 iroha924 and contributors
2// SPDX-License-Identifier: MIT
3
4// Picks Sphica's own text out of a classic hook result and fits it to the pane. Hook results carry no mark of which hook wrote what,
5// so a match on Sphica's fixed wording is a guess for display only; server/test/pane-sift.test.ts holds it to deliver.ts's real output.
6
7import type { Delivered } from "../types/index.d.ts";
8
9export type { Delivered };
10
11/** What the mod reads from a classic result: its contexts, and a PreToolUse refusal. */
12export type Result = { readonly additionalContext?: readonly string[]; readonly deny?: string } | undefined;
13
14export const LIMITS = { kept: 100, chars: 8000, each: 1500 } as const;
15
16const isSphica = (text: string) => text.startsWith("Sphica") || text.includes("Sphica's read");
17
18export function sift(result: Result, event: string, at: string): Delivered[] {
19 const kept: Delivered[] = (result?.additionalContext ?? [])
20 .filter(isSphica)
21 .map((text) => ({ at, event, kind: "context", text }));
22 if (typeof result?.deny === "string" && isSphica(result.deny))
23 kept.push({ at, event, kind: "stop", text: result.deny });
24 return kept;
25}
26
27export const heading = (row: Delivered) => `${row.at} ${row.event}${row.kind === "stop" ? " (stopped)" : ""}`;
28
29export const omitted = (cut: number, hidden: number) =>
30 [hidden > 0 ? `${hidden} older not shown` : "", cut > 0 ? `${cut} cut to ${LIMITS.each} characters` : ""]
31 .filter(Boolean)
32 .join(", ");
33
34export type Layout = { shown: Delivered[]; cut: number; hidden: number };
35
36/** The newest first, each cut to `each` characters, headings and the omission line counted, until `chars` in all. */
37export function layout(
38 list: readonly Delivered[],
39 chars: number = LIMITS.chars,
40 each: number = LIMITS.each,
41): Layout {
42 const room = chars - omitted(list.length, list.length).length;
43 const shown: Delivered[] = [];
44 let used = 0;
45 let cut = 0;
46 for (const row of [...list].reverse()) {
47 const text = row.text.length > each ? `${row.text.slice(0, each - 1)}…` : row.text;
48 const size = heading(row).length + text.length;
49 if (used + size > room) break;
50 used += size;
51 if (text !== row.text) cut += 1;
52 shown.push({ ...row, text });
53 }
54 return { shown, cut, hidden: list.length - shown.length };
55}
56types/index.d.ts 12 lines1// Copyright (c) 2026 iroha924 and contributors
2// SPDX-License-Identifier: MIT
3
4/** One piece of Sphica's text a hook handed the agent, as the pane keeps it. */
5export type Delivered = { at: string; event: string; kind: "context" | "stop"; text: string };
6
7declare module "claude-code" {
8 interface PluginState {
9 "sphica-pane": { delivered: Delivered[] };
10 }
11}
12