SLOPSHOPPER

Telltale

See what your skills and MCP servers put in front of Claude, which tools it called, what it read back, and what it did next. Observe-only; writes local logs…

newpanebandrowsguardcommand
v0.3.12MITupdated 2026-10-09j0hanz/j0hanz-marketplace/plugins/telltale
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · telltale
│ ┃ telltale ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Calls 2: Inventory │ ┃ TOOL SERVER TIME TOK NEXT ⏺ Read(src/auth.ts) │ ┃ turn 1 · 9 calls · ~171 tok ⎿ Read 6 lines │ ┃ Bash - 20ms ~28 aborted ⏺ Update(src/auth.ts) │ ┃ Bash - 20ms ~0 aborted ⎿ Added 2 lines, removed 1 line │ ┃ Bash - 20ms ~8 aborted ⏺ Bash(bun test) │ ┃ Bash - 20ms ~40 ✗ aborted ⎿ 3 pass, 1 fail │ ┃ Write - 20ms ~13 aborted │ ┃ Write - 20ms ~13 aborted ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Edit - 20ms ~12 aborted │ ┃ Grep - 20ms ~8 aborted ✻ Worked for 42s · done 4:20 PM │ ┃ Read - 20ms ~49 aborted │ ┃ ↑↓ select · enter open · esc close › /telltale │ ⎿ telltale: opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · telltale
1: Calls 2: Inventory TOOL SERVER TIME TOK NEXT turn 1 · 9 calls · ~171 tok Bash - 20ms ~28 aborted Bash - 20ms ~0 aborted Bash - 20ms ~8 aborted Bash - 20ms ~40 ✗ aborted Write - 20ms ~13 aborted Write - 20ms ~13 aborted Edit - 20ms ~12 aborted Grep - 20ms ~8 aborted Read - 20ms ~49 aborted ↑↓ select · enter open · esc close
README

Telltale

License

See what your skills and MCP servers put in front of Claude, which tools it called, what it read back, and what it did next.

What it is

Telltale is a Claude Code mod (a hooks module). It only observes: it never changes, delays or blocks a tool call, and it opens no network connection. It writes local files only. It needs Claude Code 2.1.288 or later (built and tested against 2.1.294). Copilot CLI does not run mods, so the plugin carries the claude-code-only catalog tag.

Install

/plugin install telltale@j0hanz-marketplace

To try it from a clone of the marketplace, start Claude Code with --plugin-dir plugins/telltale.

A turn, end to end

After a main-thread turn that called an MCP tool or expanded a skill, one line appears under the answer:

telltale: 2 MCP calls · 1 error · ~2.1k tok

Segments appear only when non-zero, and a skills: a, b segment lists expanded skills. Tokens are characters divided by 4, rounded up per call. The count prints as an integer below 1,000, else one decimal with k. Built-in tools are never counted.

While you work, without opening anything:

  • Status line. Session totals since start or the last /clear, with Claude Code's share of the context window in use:
  telltale · 7 MCP · 1✗ · ~12.6k tok · ctx 61%
  • Band above the prompt. While an MCP call runs, it shows the longest-running call, its elapsed time, and the turn so far. It is empty otherwise.
  ◐ db.run_query 3s · turn: 2 MCP · ~2.2k tok · 1✗
  • Transcript. Each completed MCP call's row gets one dim line beneath its tool line: 812ms · ~1.9k tok, plus · error when it failed. A folded run of calls gets one such line beneath its count line instead, summing its MCP calls: 3 MCP calls · ~1.1k tok, plus · 1 error when any failed.
  • Toast. At most one per turn, for an MCP call that failed (db.run_query failed · /telltale) or that returned 40,000 characters or more (github.search returned ~15.0k tok). A failure toast shows as the call completes; a large-result toast waits for the turn to end, and shows only when no call failed that turn. Each tool toasts once for a failure and once for a large result; it toasts again after you open /telltale or run /clear. The status entry's ✗ count still counts every failure.

Run /telltale to open the pane on the Calls view, with the newest call selected. Calls is a table (TOOL, SERVER, TIME, TOK, NEXT), grouped by turn under turn <N> separators whose numbers match the log files. Failures are marked ✗ in the error colour. retried, aborted and pending show in the warning colour. If the pane cannot be drawn, for example on a surface that places no panes, the reply says so and gives the reason.

Press Enter to open a call's detail. Its header reads tool · server · agent · time · chars · tokens · next; agent shows only on a subagent's call, as its task and type. The arguments and the text Claude read follow, coloured as JSON when they are JSON, then the values from the result that showed up in the answer. Press n and p to step to the older and newer call, c and y to copy the arguments or the result, and b to go back to the list.

Press 2 for the Inventory, which shows what each MCP server, skill and memory file costs in context: totals and shares of the window, a bar per server, how often each server was called, and never called on tools loaded but not used. Press m there to measure exactly. In the fullscreen layout the pane docks beside the transcript and lets toasts show while it stays open.

/telltale [calls|inventory] picks the view. An empty argument or calls opens Calls, and inventory opens Inventory. Anything else opens Calls and replies unknown view "<arg>"; views: calls, inventory.

The pane

KeyWhereAction
1anywhereCalls view
2anywhereInventory view
Up / DownCallsmove the selection
EnterCallsopen the detail of the selected call
bdetailreturn to the list
n / pdetailthe next older / newer call
c / ydetailcopy the arguments / the result text the pane keeps (the first 20,000 characters)
mInventorymeasure: ask Claude Code for an exact token count of MCP tools and memory files (skills stay estimates)
Escanywhereclose the pane

Settings

Set these in the plugin's settings in Claude Code.

SettingDefaultMeaning
logDir.claude/telltaleFolder for per-turn logs, relative to the directory the session started in, or an absolute path
fullPayloadsfalseAlso log each tool result's full text, redacted, not just the 300-character head and tail

Logs

Files live under logDir, relative to the directory the session started in (a later cd does not move them):

  • <logDir>/.gitignore contains *. It is written before the first log file.
  • <logDir>/<session-id>/turn-<N>.jsonl holds one turn's records. When a turn's records exceed 1,000,000 characters they go to turn-<N>-part<K>.jsonl files, each under that size. No record is dropped. N continues from the highest number already in the folder, so a resumed session appends. Records captured after the last turn of a session, such as a subagent's call after the final answer, are written to that turn's file when the session ends.
  • <logDir>/<session-id>/context-<N>.json is written at the end of the first turn after the conversation's starting context is built or rebuilt: session start, resume, after /clear, after a compaction.

A turn with no records writes no file.

call record

One JSON object per line.

fieldtypemeaning
type"call"
toolstringtool name, e.g. mcp__orders__search or Read
serverstring or nullthe <server> in mcp__<server>__<tool>; null for built-in tools
agentIdstring or nullsubagent id, null on the main thread
modelstring or nullthe model that answered the response that issued the call (the one the request named when no usage was reported); null when no model response streamed the call
effortstring, number or nullthe thinking effort that response's request asked for; null for a model without effort
msnumberwall time, including any permission decision; see permission
permissionstring or nullallow (no permission dialog or classifier, see Known limits), ask (put to the dialog or the auto-mode classifier, inside ms) or deny; the verdict of the rules and the hooks beneath telltale; null when none was seen
permissionRulestringthe settings rule that decided, as written, redacted; only when a rule decided
argsobjectthe arguments, redacted; every string cut to 2,000 characters followed by …[cut N chars]
charsnumbercharacters of result text Claude read
estTokensnumberceil(chars / 4)
ctxDeltanumbermeasured growth of the agent's context after this call, in tokens: the next response's API-reported input minus this response's input and output. Includes reminders and hook context Claude Code added in between. Only when the call was the only tool its response asked for; absent otherwise
isErrorbooleanresult was an error or was denied
blocksstring[]content block kinds in the result (e.g. ["text"], ["image"])
head, tailstringfirst / last 300 characters of the redacted result text
nextstringwhat Claude did next: answered, retried (same tool), other-tool, asked-user, aborted, pending (subagent still running when the file was written)
usedInAnswerstring[]up to 5 values (at least 4 chars, with a digit) that appear in both the result and the final answer, in result order
textstringthe full redacted result text; only when fullPayloads is true
truncatedtrueonly when a single record exceeded the part limit and its largest fields were replaced by …[cut N chars]

skill record

{ "type": "skill", "skill": "<name>", "chars": 1200, "estTokens": 300 }

apiTool record

{ "type": "apiTool", "id": "sv1", "name": "advisor", "agentId": null, "args": {}, "ms": 250 }

A tool the API ran itself inside a model response (the advisor is one). Claude Code runs no tool hooks for it, so it never counts in the receipt, the totals or the pane, and has no chars or estTokens: the API reports no result. id is the API's id for the use, one record per id in a turn. args are redacted and cut like a call's. ms is the time the API stamped from start to result, null when the response ended first.

context-<N>.json

{
  "reason": "start",
  "version": "2.1.300",
  "files": [{ "path": "CLAUDE.md", "kind": "...", "chars": 0 }],
  "tools": [{ "tool": "...", "server": "...", "chars": 0, "deferred": false }]
}

reason is start, clear or compact. files are the instruction files loaded (CLAUDE.md and imports). tools are the tool descriptions sent so far: chars is the length of the description text, and deferred is true for a deferred MCP tool listing. version is the Claude Code version the session ran on, as claude --version prints it; absent when Claude Code could not report it.

Reading the logs

jq -r 'select(.type=="call") | [.server // "-", .tool, .ms, .estTokens, .next] | @tsv' .claude/telltale/*/turn-*.jsonl

Compare the estimate with the measured growth:

jq -r 'select(.ctxDelta) | [.tool, .estTokens, .ctxDelta] | @tsv' .claude/telltale/*/turn-*.jsonl

Known limits

A reload of the mod (editing it under --plugin-dir, or changing its settings) during a turn loses that turn's records made before the reload from its log file; the pane keeps its rows. A call whose model response began before the reload gets its next label from incomplete data. A call still running when the mod reloads leaves the band and is not recorded.

permission is the verdict the engine's rules and the plugins beneath telltale reached. A plugin loaded above telltale may still change it, and the log does not show that. For a tool that always needs the person (a question put to them, or a plan to approve), an allow does not dismiss the dialog, so that call's duration includes the person's time even when the detail says no permission dialog was in it. A call whose verdict was never seen (a row saved by an older telltale, or a call still running when the mod reloaded) shows the old caveat in the detail.

Redaction

Redaction applies to the logs only. The pane shows raw data for the 200 most recent calls; that data stays in the mod's memory and is not readable by other plugins.

Before any cut, these patterns are replaced by [redacted] in argument values and result text:

  • Anthropic keys (sk-ant- prefix) and other sk- keys of 20 or more characters
  • GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_, 36 or more characters) and github_pat_ tokens
  • GitLab personal access tokens (glpat- prefix, 20 or more characters)
  • Stripe live secret and restricted keys and webhook signing secrets (sk_live_, rk_live_, whsec_, 20 or more characters)
  • npm tokens (npm_ prefix, 20 or more characters)
  • AWS access key IDs (AKIA or ASIA plus 16 characters)
  • Slack tokens (xox followed by a, b, p or r, then a dash)
  • Google API keys (AIza plus 35 characters)
  • bearer tokens (the token part; the word matches whatever its case)
  • JWTs (three dot-separated parts, starting eyJ)
  • private key blocks (BEGIN ... PRIVATE KEY to END)

A match may not follow a letter, digit, _ or -, unless that letter ends a JSON escape (\n, \r, \t).

The whole value of any field named password, passwd, secret, token, api_key, x_api_key, authorization, access_token, refresh_token, id_token, auth_token, session_token, client_secret, private_key, aws_secret_access_key, cookie, set_cookie, secret_key or passphrase is replaced too. Names match whatever the case, and with _, - or nothing between their words, so accessToken, Access-Token and ACCESS_TOKEN all match access_token. This also applies inside JSON text, such as a result that is a JSON document, when the value is a string. Deeper JSON escaping (a JSON string inside a JSON string inside another) and non-string values inside JSON text are not matched. usedInAnswer values are checked one by one, outside their JSON, so only the patterns above apply to them.

Tool, server and skill names, paths and field names are never redacted.

Headless runs

Under claude -p the mod writes the same files and prints nothing of its own to stdout or stderr. No receipt is shown, and /telltale replies the pane needs an interactive session. A log-write failure goes to Claude Code's debug log (--debug-file <path>). In an interactive session the same failure shows one toast per session naming the folder.

Requirements index

The source and tests cite these IDs (R1 to R51, and "delta R12" and similar). Each line is the current wording. Update the matching line whenever behaviour changes.

  • R1 After a main-thread turn in which Claude or a subagent called an MCP tool or expanded a skill, one receipt line telltale: … appears under the answer with MCP call count, error count, ~t tok (chars ÷ 4, rounded up per call; k from 1,000 with one decimal) and skills: once each in first-expansion order; built-in tools never count; a call another plugin made via $.tool.call never counts (its id was never streamed by a model response), while a call Claude streamed counts whichever plugin's origin it carries; when another hook already set a line under the answer, the receipt follows it.
  • R2 Subagent turns show no receipt; their calls count in the main turn that was running, or, between turns, in the next main turn that ends.
  • R3 Everything that reaches Claude (tool calls, results, descriptions, skill text, system prompt) is byte-identical to a session without the mod; a fault inside the mod never alters or blocks a call.
  • R4 Each completed call is recorded with tool, server, args, ms, chars, estTokens, isError, block kinds, 300-character head and tail, agentId, the model that answered the response that issued it (else the one requested) and the effort requested, next and usedInAnswer, the permission verdict and rule (R50), and ctxDelta when R51 gives one.
  • R5 A main-thread turn with at least one record writes that turn's records, one JSON object per line, to its own file (or numbered parts under R15); a turn with no record writes nothing; records that belong to a turn still in flight when the session ends are written at session end, under the number they were captured for.
  • R6 Under claude -p the same files are written; nothing of the mod's own goes to stdout or stderr; a write failure is reported only through Claude Code's debug log; /telltale replies the pane needs an interactive session.
  • R7 Each skill expansion is recorded with skill name, chars and estTokens in the turn it belongs to.
  • R8 When the first turn after a context build ends, a new context-<N>.json is written (N past the highest present) with reason start, clear or compact, the instruction files loaded, the tool descriptions sent, and the Claude Code version; only a main-conversation compaction that went ahead counts as compact.
  • R9 /telltale opens the pane at any width, or reuses and focuses it, showing the view R23 picks, with the newest row selected in Calls; if the pane opens but is not drawn (no attached surface places panes) the reply is pane opened but not drawn: <reason>, and if a ui.open hook refuses it the reply is pane not opened: followed by the engine's error message, which carries the hook's reason.
  • R10 Calls lists the most recent calls since session start or the last /clear, up to the 200 R25 keeps, newest first, each row with tool, server, duration, estimated tokens, an error marker and the next action, laid out as R32 says (server and next left out below 50 columns); an empty list shows No tool calls yet and logs: <folder>, relative to the starting directory with / separators.
  • R11 Up/Down move the selection, Enter opens the detail; new calls do not move an existing selection; the detail shows args and the text Claude read (pretty-printed only when the compact JSON round-trips losslessly and the indented form serializes (JSON.stringify) to at most 45,000 characters), size, error flag, next action, the permission note (R50) and used-in-answer; b returns with the same row selected; Esc closes the pane; with nothing selected, or the selection evicted, the newest call is selected.
  • R12 At main-turn end each call is labelled from its agent's next complete response: pending (none yet, agent running), aborted (none, agent stopped), answered (no tool), retried (same tool), asked-user (AskUserQuestion), other-tool; calls from one response share a label; a pending row is relabelled in the pane when its agent responds, and becomes aborted if the agent stops; when the agent list cannot be read at turn end, every subagent counts as running; the written file keeps pending.
  • R13 For each call in the turn, up to 5 values (maximal runs of [A-Za-z0-9_.:/-], trailing .:/- trimmed, ≥4 chars, with a digit) that appear in both the result and the main answer, in result order; no answer means an empty list.
  • R14 Before any cut or preview, the liste
Source 3 files
hooks/register.tsx 1466 lines
1// telltale: an observe-only mod. Every hook hands back exactly what `next(e)` returned (R3);
2// its own work runs inside `safe`, so a fault here never changes or blocks a call.
3
4import { atom, read, update } from 'claude-code';
5import type {
6  EngineInterface,
7  Register,
8  RenderElement,
9  RenderInput,
10  RenderSurface,
11  SessionUsage,
12  UiOpenResult,
13} from 'claude-code';
14
15import type { Call, CallDetail, Inventory, InventoryRow, NextAction, Totals, View } from '../types';
16import {
17  bandRow,
18  callTable,
19  chunks,
20  clip,
21  ctxDelta,
22  cutArgs,
23  detailHeader,
24  estTokens,
25  formatDur,
26  formatTokens,
27  isAbsolute,
28  isJson,
29  labelCalls,
30  logsPath,
31  mcpServer,
32  pct,
33  permissionNote,
34  plural,
35  pretty,
36  receipt,
37  redact,
38  serverHeading,
39  serverTool,
40  show,
41  skillGroup,
42  statusLine,
43  toParts,
44  toolName,
45  usageText,
46  usedInAnswer,
47  type Response,
48} from './lib';
49
50const PANE = 'telltale';
51const KEEP = 200; // R25
52const SHOWN = 20_000; // R20
53// R35: each view's key row.
54const KEYS_CALLS = '↑↓ select · enter open · esc close';
55const KEYS_DETAIL = 'n/p older/newer · c copy args · y copy result · b back';
56const KEYS_INVENTORY = 'm measure · esc close';
57
58const calls = atom({ plugin: 'telltale', key: 'calls' } as const, []);
59const dropped = atom({ plugin: 'telltale', key: 'dropped' } as const, 0);
60const view = atom({ plugin: 'telltale', key: 'view' } as const, 'calls');
61const selected = atom({ plugin: 'telltale', key: 'selected' } as const, null);
62const EMPTY_INVENTORY: Inventory = { rows: [], status: 'idle', window: null, measured: {} };
63// R21, R47: `measured` holds the last `m` press's figures, by row id, until the next press.
64const inventory = atom({ plugin: 'telltale', key: 'inventory' } as const, EMPTY_INVENTORY);
65const logFolder = atom({ plugin: 'telltale', key: 'folder' } as const, '');
66// R10: the directory the session started in, which the empty list's `logs:` path is relative to.
67const startedIn = atom({ plugin: 'telltale', key: 'start' } as const, '');
68// Kept in state so a reload (a code edit, or a settings change) neither repeats the notice (R16)
69// nor strands a `pending` row (delta R12).
70const warnedOnce = atom({ plugin: 'telltale', key: 'warned' } as const, false);
71const pendingIds = atom({ plugin: 'telltale', key: 'pending' } as const, {});
72// R36, R47: subagent names by id. Only added to: the engine drops a finished agent from its list.
73const agentNames = atom({ plugin: 'telltale', key: 'agents' } as const, {});
74// R47: what a hot reload keeps beyond the rows: totals, the turn counter, the turn's toast and the tools already toasted.
75const NO_TOTALS: Totals = { calls: 0, errors: 0, tokens: 0, skills: [], ctx: null, tools: {} };
76const totals = atom({ plugin: 'telltale', key: 'totals' } as const, NO_TOTALS);
77const running = atom({ plugin: 'telltale', key: 'running' } as const, {});
78const tick = atom({ plugin: 'telltale', key: 'tick' } as const, 0);
79const turnCount = atom({ plugin: 'telltale', key: 'turnNo' } as const, 0);
80const toastedTurn = atom({ plugin: 'telltale', key: 'toastedTurn' } as const, -1);
81// R28: a large result's toast waits for its turn to end, so an error that lands
82// later in the turn still takes the turn's one toast.
83const deferredToast = atom({ plugin: 'telltale', key: 'deferred' } as const, null);
84// R28: a tool toasts once per kind until the person opens /telltale or runs /clear, so a
85// flaky tool stops toasting every turn while another tool's first failure still does.
86const toastedNames = atom({ plugin: 'telltale', key: 'toastedNames' } as const, []);
87const message = atom({ plugin: 'telltale', key: 'message' } as const, null);
88
89type Captured = {
90  id: string;
91  tool: string;
92  server: string | null;
93  agentId: string | null;
94  response: number | null;
95  ms: number;
96  args: Record<string, unknown>;
97  text: string;
98  isError: boolean;
99  blocks: string[];
100  turn: number; // R34
101  permission?: 'allow' | 'ask' | 'deny'; // R50
102  rule?: string; // R50: the settings rule that decided, raw; logged redacted, never put in state
103};
104type Done = { next: NextAction; used: string[] };
105type Turn = {
106  calls: Captured[];
107  skills: { skill: string; chars: number }[];
108  context: { reason: string; files: { path: string; kind: string; chars: number }[] } | null;
109  apiTools: {
110    id: string;
111    name: string;
112    agentId: string | null;
113    input: unknown;
114    ms: number | null;
115  }[];
116};
117
118const safe = async <T,>(work: () => Promise<T> | T): Promise<T | undefined> => {
119  try {
120    return await work();
121  } catch {
122    return undefined;
123  }
124};
125
126const blockKinds = (result: unknown): string[] => {
127  const content = (result as { content?: unknown } | null)?.content;
128  return Array.isArray(content)
129    ? content.map((block) => String((block as { type?: unknown }).type))
130    : [];
131};
132
133// Session state. A reload runs the module afresh; session.start restores the folder, the
134// start directory and the turn counter from `$.state`.
135let logDir = '.claude/telltale';
136let root = '';
137let fullPayloads = false;
138let interactive = true;
139let folder = '';
140let startDir = '';
141let turnNo = 0;
142let contextNo = 0;
143let rootReady = false;
144let warned = false;
145let pendingReason: 'clear' | 'compact' | null = null;
146let buffer: Turn = { calls: [], skills: [], context: null, apiTools: [] };
147// ponytail: responses and callResponse grow ~200 B per model request for the process life;
148// prune per agent if sessions ever run 100k+ requests.
149const responses: Record<string, Response[]> = {};
150const callResponse = new Map<string, { agent: string; index: number }>();
151// Calls labelled `pending` at their turn end, by id, with their agent (delta R12).
152const pending = new Map<string, string>();
153// Pending ids carried over a reload: their response index belongs to the old module.
154const stale = new Set<string>();
155const forget = (id: string) => {
156  pending.delete(id);
157  stale.delete(id);
158};
159const savePending = ($: EngineInterface) =>
160  safe(() => update($, pendingIds, () => Object.fromEntries(pending)));
161const described = new Map<string, { server: string | null; chars: number; deferred: boolean }>();
162// R50: each real call's permission verdict from the tool.check tiers beneath telltale, by id.
163// The call's own tool.call hook takes the entry out once its `next` settles, so none outlive it.
164const verdicts = new Map<string, { decision: 'allow' | 'ask' | 'deny'; rule?: string }>();
165// R25: args, result text and used values of the calls the pane lists, by id. Kept here, not in
166// `$.state`, which every plugin can read and which refuses a value over 4 MiB.
167const details = new Map<string, CallDetail>();
168// R3: pane bookkeeping runs after the result is handed back, one step at a time, in call order.
169// The pane and turn end wait for it, so they never see a row missing.
170let settled: Promise<unknown> = Promise.resolve();
171const later = (work: () => Promise<unknown>) => {
172  settled = settled.then(() => safe(work));
173  return settled;
174};
175
176async function write($: EngineInterface, name: string, text: string) {
177  try {
178    if (!rootReady) {
179      if (!(await $.fs.exists(`${root}/.gitignore`))) await $.fs.write(`${root}/.gitignore`, '*\n'); // R19
180      rootReady = true;
181    }
182    await $.fs.write(`${folder}/${name}`, text);
183  } catch {
184    // R16: one notice per session, also across a reload.
185    if (warned || (await safe(() => read($, warnedOnce)))) return;
186    warned = true;
187    await safe(() => update($, warnedOnce, () => true));
188    const notice = `telltale: cannot write logs to ${folder}`;
189    if (interactive) await safe(() => $.ui.toast(notice));
190    else await safe(() => $.ui.log(notice, { to: 'debug' })); // delta R6
191  }
192}
193
194/** R4, R5, R7, R8, R15, R51: one turn's records, and its context record when it has one. */
195async function writeLogs($: EngineInterface, turn: Turn, done: Map<string, Done>, n: number) {
196  const records = [
197    ...turn.calls.map((call) => {
198      const text = redact(call.text) as string;
199      const from = callResponse.get(call.id);
200      const issued = from ? responses[from.agent]?.[from.index] : undefined;
201      const delta = ctxDelta(responses[call.agentId ?? 'main'] ?? [], call.response);
202      return JSON.stringify({
203        type: 'call',
204        tool: call.tool,
205        server: call.server,
206        agentId: call.agentId,
207        model: issued?.model ?? null,
208        effort: issued?.effort ?? null,
209        ms: call.ms,
210        permission: call.permission ?? null, // R50: null when no verdict was seen
211        ...(call.rule ? { permissionRule: redact(call.rule) } : {}),
212        args: cutArgs(redact(call.args)),
213        chars: call.text.length,
214        estTokens: estTokens(call.text.length),
215        ...(delta === undefined ? {} : { ctxDelta: delta }), // R51
216        isError: call.isError,
217        blocks: call.blocks,
218        head: text.slice(0, 300),
219        tail: text.slice(-300),
220        next: done.get(call.id)!.next,
221        usedInAnswer: redact(done.get(call.id)!.used), // R14: the pane keeps the raw values (R25)
222        ...(fullPayloads ? { text } : {}),
223      });
224    }),
225    ...turn.skills.map((skill) =>
226      JSON.stringify({ type: 'skill', ...skill, estTokens: estTokens(skill.chars) }),
227    ),
228    ...turn.apiTools.map((use) =>
229      JSON.stringify({
230        type: 'apiTool',
231        id: use.id,
232        name: use.name,
233        agentId: use.agentId,
234        args: cutArgs(redact(use.input)),
235        ms: use.ms,
236      }),
237    ),
238  ];
239  if (records.length > 0) {
240    const parts = toParts(records);
241    for (const [k, part] of parts.entries()) {
242      const name = parts.length === 1 ? `turn-${n}.jsonl` : `turn-${n}-part${k + 1}.jsonl`;
243      await write($, name, `${part.join('\n')}\n`);
244    }
245  }
246  if (turn.context) {
247    contextNo += 1;
248    const tools = [...described].map(([tool, info]) => ({ tool, ...info }));
249    // R8: which Claude Code built this context, so logs compare across versions.
250    const version = (await safe(() => $.session.version()))?.version;
251    await write(
252      $,
253      `context-${contextNo}.json`,
254      JSON.stringify({ ...turn.context, version, tools }),
255    );
256  }
257}
258
259/** R10, R11, R25: a call's pane row and detail, keeping the newest 200. */
260async function listCall($: EngineInterface, call: Captured) {
261  const json = JSON.stringify(call.args);
262  const { args: _args, text: _text, blocks: _blocks, rule: _rule, ...meta } = call;
263  const shown: Call = { ...meta, argsChars: json.length, textChars: call.text.length, next: null };
264  details.set(call.id, { args: json.slice(0, SHOWN), text: call.text.slice(0, SHOWN), used: [] });
265  let gone: Call[] = [];
266  const kept = await update($, calls, (list) => {
267    const all = [...list, shown];
268    gone = all.slice(0, Math.max(0, all.length - KEEP));
269    return all.slice(gone.length);
270  });
271  // Only the ids this update evicted: a parallel call may have set its detail meanwhile.
272  for (const one of gone) details.delete(one.id);
273  const evicted = gone.length;
274  if (evicted > 0) await update($, dropped, (n) => n + evicted);
275  // delta R11: with nothing selected, or the selection evicted, the newest kept call takes it.
276  const newest = kept.at(-1)?.id ?? null;
277  let moved = false;
278  await update($, selected, (current) => {
279    moved = current === null || !kept.some((one) => one.id === current);
280    return moved ? newest : current;
281  });
282  if (moved) void focusRow($, newest);
283}
284
285/** Puts the focus ring on a row; a pane without the keys answers `{ deny }`, which is fine. */
286async function focusRow($: EngineInterface, id: string | null) {
287  if (id !== null) await $.ui.focus({ requestId: PANE, key: `row:${id}` }).catch(() => {});
288}
289
290/** delta R12: a `pending` call's label, once its agent's next complete response arrives. */
291async function relabel($: EngineInterface, agent: string, index: number) {
292  if (![...pending.values()].includes(agent)) return;
293  let labels: Record<string, NextAction> = {};
294  // Decided inside the updater: a `$.state.get` in this dispatch may read an older moment.
295  await update($, calls, (all) => {
296    const waiting = all.filter(
297      (call) =>
298        pending.get(call.id) === agent &&
299        call.next === 'pending' &&
300        (stale.has(call.id) || (call.response !== null && call.response < index)),
301    );
302    labels = labelCalls(
303      responses,
304      // A stale call is labelled from the first complete response since the reload.
305      waiting.map((call) => ({
306        id: call.id,
307        tool: call.tool,
308        agent,
309        response: stale.has(call.id) ? -1 : call.response,
310      })),
311      () => true,
312    );
313    return all.map((call) => (labels[call.id] ? { ...call, next: labels[call.id]! } : call));
314  });
315  for (const [id, label] of Object.entries(labels)) {
316    if (label !== 'pending') forget(id);
317  }
318  await savePending($);
319}
320
321/** Opens a view; the Inventory loads Claude Code's own estimate, which sends no request. */
322async function openView($: EngineInterface, next: View) {
323  await update($, message, () => null); // R39: a message lasts until the view changes
324  await update($, view, () => next);
325  if (next === 'inventory') await loadInventory($);
326}
327
328// Checked and set with no await in between, so two presses cannot both start a count.
329let measuring = false;
330const rowId = (row: InventoryRow) => `${row.group}\u0000${row.name}`;
331
332const rowsOf = (usage: SessionUsage | undefined): InventoryRow[] | null => {
333  const breakdown = usage?.context?.breakdown;
334  if (!breakdown) return null;
335  return [
336    ...(breakdown.mcpTools ?? []).map((tool) => ({
337      group: `mcp:${tool.serverName}`,
338      name: tool.name,
339      tokens: tool.tokens,
340      measured: false,
341      state: tool.isLoaded ? ('loaded' as const) : ('deferred' as const),
342    })),
343    ...(breakdown.skills?.skillFrontmatter ?? []).map((skill) => ({
344      group: `skill:${skillGroup(skill.source, skill.pluginName)}`, // delta R18
345      name: skill.name,
346      tokens: skill.tokens,
347      measured: false,
348    })),
349    ...(breakdown.memoryFiles ?? []).map((file) => ({
350      group: 'memory',
351      name: file.path,
352      tokens: file.tokens,
353      measured: false,
354    })),
355  ];
356};
357
358/** Claude Code's context breakdown, as Inventory rows, or why it could not be read. */
359async function fetchRows($: EngineInterface, breakdown: 'summary' | 'full') {
360  let usage: SessionUsage | undefined;
361  let failure = 'unknown';
362  try {
363    usage = await $.session.usage({ breakdown });
364  } catch (error) {
365    failure = error instanceof Error && error.message ? error.message : 'unknown';
366  }
367  const window = usage?.context?.breakdown?.rawMaxTokens ?? usage?.context?.window ?? null;
368  return { rows: rowsOf(usage), window, failure };
369}
370
371/** Stores fresh rows. `counted` is a count's figures; a summary keeps the last count's (R21). */
372async function storeRows(
373  $: EngineInterface,
374  rows: InventoryRow[],
375  window: number | null,
376  counted: Record<string, number> | null,
377) {
378  // Decided inside the updater, so a summary load and a count that cross keep the count's figures.
379  await update($, inventory, (inv): Inventory => {
380    const measured = counted ?? inv.measured ?? {};
381    return {
382      rows: rows.map((row) => {
383        const exact = measured[rowId(row)];
384        return exact === undefined ? row : { ...row, tokens: exact, measured: true };
385      }),
386      // A summary load landing mid-count keeps the count's `measuring` status.
387      status: counted === null && measuring ? 'measuring' : 'idle',
388      window,
389      measured,
390    };
391  });
392}
393
394/**
395 * R18, R22, R43: the Inventory from Claude Code's free local estimate. A failure shows `Context
396 * usage unavailable` when the view loads (`'unavailable'`, R22 as modified) and keeps the figures
397 * shown when a turn end reloads them (`'keep'`, R43).
398 */
399async function loadInventory($: EngineInterface, onFail: 'unavailable' | 'keep' = 'unavailable') {
400  const { rows, window } = await fetchRows($, 'summary');
401  if (rows === null) {
402    if (onFail === 'unavailable') {
403      await update($, inventory, (): Inventory => ({ ...EMPTY_INVENTORY, status: 'unavailable' }));
404    }
405    return;
406  }
407  await storeRows($, rows, window, null);
408}
409
410/** R21: `m` counts MCP tool and memory file rows exactly; skill rows stay estimates. */
411async function measureInventory($: EngineInterface) {
412  if (measuring) return;
413  // delta R21: a press while usage is unavailable changes nothing, so it never raises the flag a
414  // turn-end reload would read as `measuring`.
415  const current =
416    (await safe(() => $.state.get({ plugin: 'telltale', key: 'inventory' })))?.value ??
417    EMPTY_INVENTORY;
418  if (current.status === 'unavailable' || measuring) return;
419  measuring = true;
420  try {
421    await update($, inventory, (inv): Inventory => ({ ...inv, status: 'measuring' }));
422    const { rows, window, failure } = await fetchRows($, 'full');
423    if (rows === null) {
424      await update($, inventory, (inv): Inventory => ({
425        ...inv,
426        status: `measure failed: ${failure}`,
427      }));
428      return;
429    }
430    const present = new Set(current.rows.map(rowId));
431    const counted: Record<string, number> = {};
432    for (const row of rows) {
433      if (present.has(rowId(row)) && !row.group.startsWith('skill:')) {
434        counted[rowId(row)] = row.tokens;
435      }
436    }
437    await storeRows($, rows, window, counted);
438  } finally {
439    measuring = false;
440  }
441}
442
443/** R29: marks a counted call in flight. */
444async function startRunning($: EngineInterface, id: string, tool: string, startedAt: number) {
445  await update($, running, (list) => ({ ...list, [id]: { tool, startedAt } }));
446}
447
448/** R29, R30: the call left flight. */
449async function stopRunning($: EngineInterface, id: string) {
450  await update($, running, (list) => {
451    const { [id]: _done, ...rest } = list;
452    return rest;
453  });
454}
455
456/** R26, R46: the status entry from the session totals; none while there is nothing to show. */
457async function showStatus($: EngineInterface) {
458  if (!interactive) return;
459  try {
460    const t = await read($, totals);
461    $.ui.status(
462      statusLine({
463        calls: t.calls,
464        errors: t.errors,
465        tokens: t.tokens,
466        skills: t.skills.length,
467        ctx: t.ctx,
468      }),
469    );
470  } catch {
471    await safe(() => $.ui.status(undefined));
472  }
473}
474
475/** R28: one toast per main-thread turn, for a counted call that failed or read 40,000+ chars. */
476async function maybeToast($: EngineInterface, call: Captured) {
477  if (!interactive || call.server === null) return;
478  const large = call.text.length >= 40_000;
479  if (!call.isError && !large) return;
480  const name = serverTool(call.tool);
481  const key = `${name}:${call.isError ? 'failed' : 'large'}`;
482  // R28: a repeat stays quiet and leaves the turn's toast to another tool.
483  if ((await read($, toastedNames)).includes(key)) return;
484  if (!call.isError) {
485    // R28: the error text wins, so the large-result toast is held for the turn's end.
486    await update($, deferredToast, (held) =>
487      held?.turn === call.turn
488        ? held
489        : {
490            turn: call.turn,
491            text: `${name} returned ~${formatTokens(estTokens(call.text.length))} tok`,
492            key,
493          },
494    );
495    return;
496  }
497  let fresh = false;
498  await update($, toastedTurn, (turn) => {
499    fresh = turn !== call.turn;
500    return call.turn;
501  });
502  if (!fresh) return;
503  await update($, toastedNames, (keys) => [...keys, key]);
504  $.ui.toast(`${name} failed · /telltale`);
505}
506
507/** R26, R42, R47: a counted call joins the session totals, which outlive the 200 kept rows. */
508async function addTotals($: EngineInterface, call: Captured) {
509  if (call.server === null) return;
510  const tokens = estTokens(call.text.length);
511  const errors = call.isError ? 1 : 0;
512  await update($, totals, (t) => {
513    const tool = t.tools[call.tool] ?? { calls: 0, errors: 0, tokens: 0 };
514    return {
515      ...t,
516      calls: t.calls + 1,
517      errors: t.errors + errors,
518      tokens: t.tokens + tokens,
519      tools: {
520        ...t.tools,
521        [call.tool]: {
522          calls: tool.calls + 1,
523          errors: tool.errors + errors,
524          tokens: tool.tokens + tokens,
525        },
526      },
527    };
528  });
529}
530
531/** The pane's shared parts: the view row, the key row and the drawable width (R23, R35, R48). */
532type Frame = {
533  e: RenderInput<'Pane'>;
534  width: number;
535  tabs: RenderElement;
536  keyRow: (keys: string) => RenderElement;
537};
538
539// R48: the detail's action Buttons, and the columns they need on one row (`<hotkey>: <label>`,
540// two between them); narrower than that they stack.
541const DETAIL_BUTTONS = [
542  { key: 'back', hotkey: 'b', label: 'Back' },
543  { key: 'older', hotkey: 'n', label: 'Older' },
544  { key: 'newer', hotkey: 'p', label: 'Newer' },
545  { key: 'copyArgs', hotkey: 'c', label: 'Copy args' },
546  { key: 'copyText', hotkey: 'y', label: 'Copy result' },
547] as const;
548const DETAIL_BUTTONS_WIDTH =
549  DETAIL_BUTTONS.reduce((sum, b) => sum + `${b.hotkey}: ${b.label}`.length, 0) +
550  2 * (DETAIL_BUTTONS.length - 1);
551
552/** R36-R40: one call's detail. */
553async function drawDetail($: EngineInterface, frame: Frame, list: Call[], call: Call) {
554  const { e, width, tabs, keyRow } = frame;
555  const { Box, Text, Button, Code } = $.ui.resolve(e);
556  const detail = details.get(call.id);
557  const note = await read($, message);
558  const names = await read($, agentNames);
559  // R37: JSON is drawn as json code; a cut or non-JSON field as plain text (R11, R20).
560  const field = (key: string, label: string, raw: string, full: number) => {
561    const shown = show(pretty(raw), raw, full);
562    return [
563      <Text key={key} dimColor>
564        {label}
565      </Text>,
566      ...(isJson(shown)
567        ? [<Code key={`${key}:code`} language="json" source={shown} />]
568        : chunks(shown).map((part, i) => <Text key={`${key}:${i}`}>{part}</Text>)),
569    ];
570  };
571  // R38: n is the next older call, p the next newer; the ends change nothing.
572  const step = (by: number) => async () => {
573    await update($, message, () => null);
574    const to = list[list.findIndex((one) => one.id === call.id) + by];
575    if (to) await update($, selected, () => to.id);
576  };
577  // R39, R40: the text the pane keeps, never indented; a refusal names its reason.
578  const copy = (which: 'args' | 'text') => async (press: { surface: RenderSurface }) => {
579    if (!detail) {
580      await update($, message, () => 'copy failed: text not kept after a reload');
581      return;
582    }
583    const text = which === 'args' ? detail.args : detail.text;
584    const total = which === 'args' ? call.argsChars : call.textChars;
585    const result = await $.ui.copy({ text, surface: press.surface });
586    await update($, message, () =>
587      result.isCopied
588        ? text.length < total
589          ? `copied ${text.length} of ${total} chars`
590          : `copied ${text.length} chars`
591        : `copy failed: ${result.reason}`,
592    );
593  };
594  const presses: Record<
595    (typeof DETAIL_BUTTONS)[number]['key'],
596    (press: { surface: RenderSurface }) => Promise<void>
597  > = {
598    back: async () => {
599      await openView($, 'calls'); // clears the message too
600      await focusRow($, call.id);
601    },
602    older: step(-1),
603    newer: step(1),
604    copyArgs: copy('args'),
605    copyText: copy('text'),
606  };
607  const header = detailHeader({
608    tool: toolName(call.tool),
609    server: call.server,
610    agentId: call.agentId,
611    agentName: call.agentId ? names[call.agentId] : undefined,
612    ms: call.ms,
613    chars: call.textChars,
614    tokens: estTokens(call.textChars),
615    next: call.next,
616  });
617  const used = detail && detail.used.length > 0 ? detail.used.join(', ') : 'none';
618  return (
619    <Box flexDirection="column">
620      {tabs}
621      <Box flexDirection={width < DETAIL_BUTTONS_WIDTH ? 'column' : 'row'} columnGap={2}>
622        {DETAIL_BUTTONS.map((b) => (
623          <Button key={b.key} hotkey={b.hotkey} plain onPress={presses[b.key]}>
624            {b.label}
625          </Button>
626        ))}
627      </Box>
628      <Box flexDirection="row">
629        <Text bold>{clip(header, width - (call.isError ? 8 : 0))}</Text>
630        {call.isError && <Text color="error"> · error</Text>}
631      </Box>
632      {detail ? (
633        [
634          ...field('args', 'arguments', detail.args, call.argsChars),
635          ...field('text', 'what Claude read', detail.text, call.textChars),
636        ]
637      ) : (
638        <Text dimColor>{clip('text not kept after a reload; see the logs', width)}</Text>
639      )}
640      <Text dimColor>{clip(permissionNote(call.permission), width)}</Text>
641      <Text>{clip(`used in answer: ${used}`, width)}</Text>
642      {note !== null && <Text>{clip(note, width)}</Text>}
643      {keyRow(KEYS_DETAIL)}
644    </Box>
645  );
646}
647
648/** R18, R21, R22, R41-R43: what each MCP server, skill and memory file costs in context. */
649async function drawInventory($: EngineInterface, frame: Frame) {
650  const { e, width, tabs, keyRow } = frame;
651  const { Box, Text, Button } = $.ui.resolve(e);
652  const inv = await read($, inventory);
653  // delta R21: `m` stays bound while usage is unavailable, and a press there changes nothing.
654  const measure = (
655    <Button key="measure" hotkey="m" plain onPress={() => measureInventory($)}>
656      Measure
657    </Button>
658  );
659  if (inv.status === 'unavailable') {
660    return (
661      <Box flexDirection="column">
662        {tabs}
663        <Text>Context usage unavailable</Text>
664        {measure}
665        {keyRow(KEYS_INVENTORY)}
666      </Box>
667    );
668  }
669  const used = await read($, totals);
670  const window = inv.window;
671  // R42: a server's share of the session totals is the sum of its tool rows' matches.
672  const usageOf = (rows: InventoryRow[]) => {
673    const sum = { calls: 0, errors: 0, tokens: 0 };
674    for (const row of rows) {
675      const tool = used.tools[row.name];
676      if (!tool) continue;
677      sum.calls += tool.calls;
678      sum.errors += tool.errors;
679      sum.tokens += tool.tokens;
680    }
681    return sum.calls > 0 ? sum : undefined;
682  };
683  type Sub = { name: string; rows: InventoryRow[]; tokens: number };
684  // R41: each group's total and share of the window; `heading` draws a sub-group's title row.
685  const group = (
686    prefix: string,
687    title: string,
688    empty: string,
689    heading: (one: Sub, pad: number, max: number) => string | null,
690    note: (row: InventoryRow) => string,
691  ) => {
692    const rows = inv.rows.filter((row) => row.group.startsWith(prefix));
693    const total = rows.reduce((sum, row) => sum + row.tokens, 0);
694    const share = window ? ` · ${pct(total, window)}% of ${formatTokens(window)}` : '';
695    const groups = [...new Set(rows.map((row) => row.group))].map((name) => {
696      const members = rows.filter((row) => row.group === name).sort((a, b) => b.tokens - a.tokens);
697      const tokens = members.reduce((sum, row) => sum + row.tokens, 0);
698      return { name: name.slice(prefix.length), rows: members, tokens };
699    });
700    const pad = Math.max(0, ...groups.map((one) => one.name.length));
701    const max = Math.max(0, ...groups.map((one) => one.tokens));
702    return (
703      <Box flexDirection="column">
704        <Text bold>
705          {clip(rows.length > 0 ? `${title}  ${formatTokens(total)} tok${share}` : title, width)}
706        </Text>
707        {rows.length === 0 ? (
708          <Text dimColor>{empty}</Text>
709        ) : (
710          groups.map((one) => {
711            const line = heading(one, pad, max);
712            return (
713              <Box key={one.name} flexDirection="column">
714                {line !== null && <Text>{clip(line, width)}</Text>}
715                {one.rows.map((row) => (
716                  <Text key={row.name}>
717                    {clip(
718                      `  ${row.name}  ${formatTokens(row.tokens)} tok  ${row.measured ? 'measured' : 'est'}${row.state ? `  ${row.state}` : ''}${note(row)}`,
719                      width,
720                    )}
721                  </Text>
722                ))}
723              </Box>
724            );
725          })
726        )}
727      </Box>
728    );
729  };
730  const server = (one: Sub, pad: number, max: number) =>
731    serverHeading({ ...one, pad, max, window, usage: usageText(usageOf(one.rows)) });
732  const unused = (row: InventoryRow) => (used.tools[row.name] ? '' : '  never called');
733  return (
734    <Box flexDirection="column">
735      {tabs}
736      {group('mcp:', 'MCP tools', 'No MCP servers connected', server, unused)}
737      {group(
738        'skill:',
739        'Skills',
740        'No skills listed',
741        (one) => one.name,
742        () => '',
743      )}
744      {group(
745        'memory',
746        'Memory files',
747        'No memory files loaded',
748        () => null,
749        () => '',
750      )}
751      {measure}
752      {inv.status === 'measuring' && <Text dimColor>measuring…</Text>}
753      {inv.status.startsWith('measure failed') && <Text>{clip(inv.status, width)}</Text>}
754      {keyRow(KEYS_INVENTORY)}
755    </Box>
756  );
757}
758
759/** R10, R32-R34: the calls as a table, newest first, grouped by turn. */
760async function drawCalls($: EngineInterface, frame: Frame, list: Call[], chosen: string | null) {
761  const { e, width, tabs, keyRow } = frame;
762  const { Box, Text, Button } = $.ui.resolve(e);
763  if (list.length === 0) {
764    const at = (await read($, logFolder)) || folder;
765    const start = (await read($, startedIn)) || startDir;
766    return (
767      <Box flexDirection="column">
768        {tabs}
769        <Text dimColor>No tool calls yet</Text>
770        <Text dimColor>{clip(`logs: ${logsPath(at, start)}`, width)}</Text>
771        {keyRow(KEYS_CALLS)}
772      </Box>
773    );
774  }
775  const gone = await read($, dropped);
776  const newest = [...list].reverse();
777  const table = callTable(
778    newest.map((one) => ({
779      tool: `${one.agentId ? '↳ ' : ''}${toolName(one.tool)}`,
780      server: one.server,
781      ms: one.ms,
782      tokens: estTokens(one.textChars),
783      isError: one.isError,
784      next: one.next,
785    })),
786    width,
787  );
788  // R34: each turn's call count and tokens, in one pass.
789  const byTurn = new Map<number, { calls: number; tokens: number }>();
790  for (const one of list) {
791    const sum = byTurn.get(one.turn) ?? { calls: 0, tokens: 0 };
792    byTurn.set(one.turn, { calls: sum.calls + 1, tokens: sum.tokens + estTokens(one.textChars) });
793  }
794  const rows = newest.flatMap((one, i) => {
795    const cell = table.rows[i]!;
796    const warn = one.next === 'retried' || one.next === 'aborted' || one.next === 'pending';
797    const sum = byTurn.get(one.turn)!;
798    const separator =
799      i === 0 || newest[i - 1]!.turn !== one.turn
800        ? [
801            <Text key={`turn:${one.turn}:${i}`} dimColor>
802              {clip(
803                `turn ${one.turn} · ${plural(sum.calls, 'call')} · ~${formatTokens(sum.tokens)} tok`,
804                width,
805              )}
806            </Text>,
807          ]
808        : [];
809    return [
810      ...separator,
811      <Box key={`line:${one.id}`} flexDirection="row">
812        <Button
813          key={`row:${one.id}`}
814          plain
815          {...(one.id === chosen ? { autoFocus: true as const } : {})}
816          onPress={async () => {
817            await update($, selected, () => one.id);
818            await update($, view, () => 'detail');
819          }}
820        >
821          {cell.main}
822        </Button>
823        <Text color="error">{cell.mark}</Text>
824        {!table.narrow && <Text {...(warn ? { color: 'warning' } : {})}>{cell.next}</Text>}
825      </Box>,
826    ];
827  });
828  return (
829    <Box flexDirection="column">
830      {tabs}
831      <Text bold>{clip(table.header, width)}</Text>
832      {rows}
833      {gone > 0 && <Text dimColor>{clip(`${gone} older calls are in the logs`, width)}</Text>}
834      {keyRow(KEYS_CALLS)}
835    </Box>
836  );
837}
838
839export const register: Register = (on, options) => {
840  logDir =
841    typeof options.logDir === 'string' && options.logDir ? options.logDir : '.claude/telltale';
842  fullPayloads = options.fullPayloads === true;
843
844  on('session.start', async ($, e, next) => {
845    interactive = e.isInteractive;
846    await safe(() =>
847      $.command.register({
848        name: 'telltale',
849        description: 'Open the telltale pane: calls or inventory',
850        argumentHint: '[calls|inventory]',
851      }),
852    );
853    // R19: anchored to where the session started, and kept in state so a reload or a later
854    // `cd` never moves it. One folder per session id, kept across /clear and resume.
855    folder = (await safe(() => read($, logFolder))) || '';
856    startDir = (await safe(() => read($, startedIn))) || '';
857    if (!folder) {
858      startDir = e.cwd.replace(/[\\/]+$/, '');
859      root = isAbsolute(logDir) ? logDir : `${startDir}/${logDir}`;
860      folder = `${root}/${(await safe(() => $.session.id())) ?? 'session'}`;
861      await safe(() => update($, logFolder, () => folder));
862      await safe(() => update($, startedIn, () => startDir));
863    } else {
864      root = folder.slice(0, folder.lastIndexOf('/'));
865      // A folder kept by v0.2, which kept no start directory: this session's directory stands in.
866      if (!startDir) {
867        startDir = e.cwd.replace(/[\\/]+$/, '');
868        await safe(() => update($, startedIn, () => startDir));
869      }
870    }
871    const kept = (await safe(() => read($, pendingIds))) ?? {};
872    for (const [id, agent] of Object.entries(kept)) {
873      if (!pending.has(id)) {
874        pending.set(id, agent);
875        stale.add(id);
876      }
877    }
878    for (const entry of (await safe(() => $.fs.list(folder))) ?? []) {
879      const turn = /^turn-(\d+)/.exec(entry.name);
880      const context = /^context-(\d+)\.json$/.exec(entry.name);
881      if (turn) turnNo = Math.max(turnNo, Number(turn[1]));
882      if (context) contextNo = Math.max(contextNo, Number(context[1]));
883    }
884    // R34: a failed write uses up its number, which only the kept counter remembers.
885    turnNo = Math.max(turnNo, (await safe(() => read($, turnCount))) ?? 0);
886    // A call in flight across a reload never completes in this module (README, Known limits).
887    await safe(() => update($, running, () => ({})));
888    await showStatus($); // R47: the entry comes back after a reload
889    // R29: the band's clock. It bumps `tick` once a second while a counted call runs, which
890    // redraws the band (it reads `tick`); the timer ends with the module on a reload.
891    if (interactive) {
892      await safe(() =>
893        $.clock.every(1000, () => {
894          void safe(async () => {
895            if (Object.keys(await read($, running)).length > 0) await update($, tick, (n) => n + 1);
896          });
897        }),
898      );
899    }
900    return next(e);
901  });
902
903  on('turn.step', async function* ($, e, next) {
904    const agent = e.agentId ?? 'main';
905    const list = (responses[agent] ??= []);
906    const index =
907      list.push({ toolNames: [], complete: false, model: e.model, effort: e.effort }) - 1;
908    const stream = next(e);
909    let step = await stream.next();
910    try {
911      while (!step.done) {
912        const chunk = step.value;
913        if (chunk.kind === 'tool') {
914          list[index]!.toolNames.push(chunk.name);
915          callResponse.set(chunk.id, { agent, index });
916        }
917        yield chunk;
918        step = await stream.next();
919      }
920    } finally {
921      // A consumer that stops early ends the stream beneath too.
922      if (!step.done) await stream.return?.(undefined as never);
923    }
924    const r = step.value;
925    // R4: the model that answered (a fallback, or a model a hook above rewrote) beats the request's.
926    await safe(() => {
927      if (r?.usage?.model) list[index]!.model = r.usage.model;
928      // R51: what the request cost as the API reported it; read by `ctxDelta` at log time.
929      const usage = r?.usage;
930      if (usage) {
931        list[index]!.usage = {
932          in:
933            usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
934          out: usage.output_tokens,
935          serverTools: r?.serverToolUses?.length ?? 0,
936        };
937      }
938      // R49: tools the API ran itself raise no tool.call. Logged into the running turn (as R2
939      // does for subagent calls), never counted: the API exposes no result to size.
940      for (const use of r?.serverToolUses ?? []) {
941        const ms = use.endedAt === undefined ? null : use.endedAt - use.startedAt;
942        // A paused turn may list the same use again when it continues: one record per id.
943        const seen = buffer.apiTools.find((t) => t.id === use.id);
944        if (seen) seen.ms ??= ms;
945        else
946          buffer.apiTools.push({
947            id: use.id,
948            name: use.name,
949            agentId: e.agentId ?? null,
950            input: use.input,
951            ms,
952          });
953      }
954    });
955    // A null stopReason is a request that failed or was cut off: not a response (delta R12).
956    list[index]!.complete = r?.stopReason != null;
957    if (list[index]!.complete) await safe(() => relabel($, agent, index));
958    return r;
959  });
960
961  // R50: observe the verdict only; R3: the result goes back exactly as `next` returned it.
962  // A query (`$.tool.check`) carries no tool_use_id and records nothing.
963  on('tool.check', async ($, e, next) => {
964    const r = await next(e);
965    const id = e.tool_use_id;
966    if (id) await safe(() => verdicts.set(id, { decision: r.decision, rule: r.rule }));
967    return r;
968  });
969
970  on('tool.call', async ($, e, next) => {
971    const started = (await safe(() => $.clock.now())) ?? 0;
972    // R29: a counted call is in flight from here. Queued, never awaited: the call must not wait
973    // on a state write (R3).
974    const counted =
975      mcpServer(e.tool) !== null &&
976      (next.origin.plugin === 'engine' || callResponse.has(e.tool_use_id));
977    if (counted) {
978      void later(() => startRunning($, e.tool_use_id, e.tool, started));
979      // R29, R30: an abandoned dispatch leaves flight too; a second stop changes nothing.
980      const stop = () => void later(() => stopRunning($, e.tool_use_id));
981      // An abort that came before the listener never fires again, so it is read here.
982      if (next.signal.aborted) stop();
983      else next.signal.addEventListener('abort', stop, { once: true });
984    }
985    let r: Awaited<ReturnType<typeof next>>;
986    let verdict: { decision: 'allow' | 'ask' | 'deny'; rule?: string } | undefined;
987    try {
988      r = await next(e);
989    } finally {
990      // R50: the tool.check beneath ran inside this `next`; get-and-delete, captured or not.
991      verdict = verdicts.get(e.tool_use_id);
992      verdicts.delete(e.tool_use_id);
993      if (counted) void later(() => stopRunning($, e.tool_use_id));
994    }
995    // R1: only calls Claude made. Another plugin's `$.tool.call` is not Claude's: it gets its
996    // own id, which no `turn.step` streamed. A subagent a plugin spawned carries that plugin's
997    // origin too, but its model streams each call first, so those stay counted (R2).
998    if (next.origin.plugin !== 'engine' && !callResponse.has(e.tool_use_id)) return r;
999    try {
1000      const ms = ((await safe(() => $.clock.now())) ?? started) - started;
1001      const { tool, tool_use_id: id, agentId, ...args } = e;
1002      const text = typeof r.text === 'string' ? r.text : (r.deny ?? '');
1003      const call: Captured = {
1004        id,
1005        tool,
1006        server: mcpServer(tool),
1007        agentId: agentId ?? null,
1008        response: callResponse.get(id)?.index ?? null,
1009        ms,
1010        args,
1011        text,
1012        isError: r.isError === true || r.deny !== undefined,
1013        blocks: blockKinds(r.result),
1014        // R34: the number this call's turn file will get (a call between turns joins the next).
1015        turn: turnNo + 1,
1016        permission: verdict?.decision,
1017        rule: verdict?.rule,
1018      };
1019      // Before the return: the turn end reads `buffer` for its log.
1020      buffer.calls.push(call);
1021      void later(async () => {
1022        await listCall($, call);
1023        await addTotals($, call);
1024        await showStatus($);
1025        await maybeToast($, call);
1026      });
1027    } catch {
1028      // R3: a fault here never blocks the result.
1029    }
1030    return r;
1031  });
1032
1033  on('skill.prompt', async ($, e, next) => {
1034    const r = await next(e);
1035    await safe(() => buffer.skills.push({ skill: e.skill, chars: r.text.length }));
1036    void later(async () => {
1037      await update($, totals, (t) =>
1038        t.skills.includes(e.skill) ? t : { ...t, skills: [...t.skills, e.skill] },
1039      );
1040      await showStatus($);
1041    });
1042    return r;
1043  });
1044
1045  on('tool.describe', async ($, e, next) => {
1046    const r = await next(e);
1047    await safe(() => {
1048      const plugin = e.provider.plugin;
1049      described.set(e.tool, {
1050        server: plugin.startsWith('mcp:') ? plugin.slice(4) : null,
1051        chars: r.description.length,
1052        deferred: (r.isDeferred ?? e.isDeferred) === true,
1053      });
1054    });
1055    return r;
1056  });
1057
1058  on('prompt.context', async ($, e, next) => {
1059    const r = await next(e);
1060    await safe(() => {
1061      buffer.context = {
1062        reason: pendingReason ?? 'start',
1063        files: (r.instructionFiles ?? []).map((file) => ({
1064          path: file.path,
1065          kind: file.kind,
1066          chars: file.content.length,
1067        })),
1068      };
1069      pendingReason = null;
1070    });
1071    return r;
1072  });
1073
1074  on('session.end', async ($, e, next) => {
1075    if (e.reason === 'clear') {
1076      pendingReason = 'clear';
1077      pending.clear(); // the cleared rows can never be relabelled
1078      stale.clear();
1079      // R26: work queued before the clear lands first, so nothing from before it counts after.
1080      await later(async () => {
1081        details.clear();
1082        await savePending($);
1083        await update($, calls, () => []);
1084        await update($, dropped, () => 0);
1085        await update($, view, () => 'calls');
1086        await update($, selected, () => null);
1087        await update($, totals, () => NO_TOTALS); // the old context's share goes too (R27)
1088        await update($, deferredToast, () => null); // a held toast belongs to the cleared turns (R28)
1089        await update($, toastedNames, () => []);
1090        await showStatus($);
1091      });
1092    } else {
1093      // A call made after the last turn's end belongs to a turn that never completes;
1094      // write its records here or they are lost with the process (README, Logs).
1095      await settled;
1096      const turn = buffer;
1097      buffer = { calls: [], skills: [], context: null, apiTools: [] };
1098      const hasRecords = turn.calls.length + turn.skills.length + turn.apiTools.length > 0;
1099      const n = hasRecords ? ++turnNo : turnNo; // R34: taken with the swap, like turn.complete
1100      if (hasRecords) await safe(() => update($, turnCount, () => n)); // R47: kept across a reload
1101      if (!hasRecords) return next(e);
1102      const labels = labelCalls(
1103        responses,
1104        turn.calls.map((call) => ({
1105          id: call.id,
1106          tool: call.tool,
1107          agent: call.agentId ?? 'main',
1108          response: call.response,
1109        })),
1110        () => false, // the session is over: no agent is still running
1111      );
1112      const done = new Map(
1113        turn.calls.map((call) => [call.id, { next: labels[call.id] ?? 'aborted', used: [] }]),
1114      );
1115      await safe(() => writeLogs($, turn, done, n));
1116    }
1117    return next(e);
1118  });
1119
1120  on('session.compact', async ($, e, next) => {
1121    const r = await next(e);
1122    // Only a main-conversation compaction that went ahead rebuilds the main context (R8).
1123    if (e.agentId === undefined && e.trigger !== 'precompute' && (!('skip' in r) || !r.skip)) {
1124      pendingReason = 'compact';
1125    }
1126    return r;
1127  });
1128
1129  on('turn.complete', async ($, e, next) => {
1130    const r = await next(e);
1131    if (e.agentId !== undefined) return r;
1132    const turn = buffer;
1133    buffer = { calls: [], skills: [], context: null, apiTools: [] };
1134    // R34: the number is taken with the swap, before any await, so a call that lands while this
1135    // turn ends is captured with the next number, matching the file it goes to.
1136    const hasRecords = turn.calls.length + turn.skills.length + turn.apiTools.length > 0;
1137    const n = hasRecords ? ++turnNo : turnNo;
1138    if (hasRecords) await safe(() => update($, turnCount, () => n)); // R47: kept across a reload
1139    // R1: MCP calls only, subagent calls included (R2); skills once each.
1140    const mcp = turn.calls.filter((call) => call.server !== null);
1141    const line = receipt({
1142      mcpCalls: mcp.length,
1143      errors: mcp.filter((call) => call.isError).length,
1144      tokens: mcp.reduce((sum, call) => sum + estTokens(call.text.length), 0),
1145      skills: turn.skills.map((entry) => entry.skill),
1146    });
1147    const ready = await safe(async () => {
1148      const listed = await safe(() => $.agent.list());
1149      const liveAgents = new Set(
1150        (listed ?? []).filter((agent) => agent.status === 'running').map((agent) => agent.id),
1151      );
1152      const labels = labelCalls(
1153        responses,
1154        turn.calls.map((call) => ({
1155          id: call.id,
1156          tool: call.tool,
1157          agent: call.agentId ?? 'main',
1158          response: call.response,
1159        })),
1160        // A list that failed says nothing about who stopped: every subagent then counts as
1161        // running, so its calls go `pending` and a later turn end settles them (R12 rule 2).
1162        (agent) => agent !== 'main' && (listed === undefined || liveAgents.has(agent)),
1163      );
1164      const answer = e.reason === 'answer' ? e.answer : null;
1165      const done = new Map(
1166        turn.calls.map((call) => [
1167          call.id,
1168          { next: labels[call.id] ?? 'aborted', used: usedInAnswer(call.text, answer) },
1169        ]),
1170      );
1171      return { listed, liveAgents, done };
1172    });
1173    if (ready) {
1174      const { listed, liveAgents, done } = ready;
1175      // R5, R6: the logs are the durable output. They are written first, in their own `safe`,
1176      // so a refused pane-state write (below) can never cost a turn its file.
1177      await safe(() => writeLogs($, turn, done, n));
1178      // R36: name each listed agent; merged, so an agent the engine has since dropped keeps its name.
1179      if (listed && listed.length > 0) {
1180        await safe(() =>
1181          update($, agentNames, (known) => ({
1182            ...known,
1183            ...Object.fromEntries(
1184              listed.map((agent) => [
1185                agent.id,
1186                agent.description ? `${agent.description} (${agent.type})` : agent.type,
1187              ]),
1188            ),
1189          })),
1190        );
1191      }
1192      await settled;
1193      await safe(async () => {
1194        for (const [id, found] of done) {
1195          if (details.has(id)) details.get(id)!.used = found.used;
1196        }
1197        await update($, calls, (list) =>
1198          list.map((call) => {
1199            const found = done.get(call.id);
1200            return found ? { ...call, next: found.next } : call;
hooks/lib.ts 535 lines
1// Pure helpers for the telltale mod: no `$`, no I/O, so `claude plugin test` checks them
2// directly. Requirement IDs (R1 to R51) are indexed in ../README.md, "Requirements index".
3
4import type { NextAction } from '../types';
5
6/** The server out of `mcp__<server>__<tool>`, or null for a built-in tool. */
7export const mcpServer = (tool: string): string | null => {
8  if (!tool.startsWith('mcp__')) return null;
9  const end = tool.indexOf('__', 5);
10  return end === -1 ? null : tool.slice(5, end);
11};
12
13/** R1: estimated tokens of a text, characters / 4 rounded up. */
14export const estTokens = (chars: number): number => Math.ceil(chars / 4);
15
16/** R1: an integer below 1,000, otherwise thousands to one decimal with `k`. */
17export const formatTokens = (t: number): string =>
18  t < 1000 ? String(t) : `${(Math.round(t / 100) / 10).toFixed(1)}k`;
19
20export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
21
22/** R1: the receipt line, or null when the turn used no MCP tool and no skill. */
23export const receipt = (turn: {
24  mcpCalls: number;
25  errors: number;
26  tokens: number;
27  skills: string[];
28}): string | null => {
29  const parts: string[] = [];
30  if (turn.mcpCalls > 0) parts.push(plural(turn.mcpCalls, 'MCP call'));
31  if (turn.errors > 0) parts.push(plural(turn.errors, 'error'));
32  if (turn.mcpCalls > 0) parts.push(`~${formatTokens(turn.tokens)} tok`);
33  if (turn.skills.length > 0) parts.push(`skills: ${[...new Set(turn.skills)].join(', ')}`);
34  return parts.length > 0 ? `telltale: ${parts.join(' · ')}` : null;
35};
36
37// R14: written with `_` between words; a key matches ignoring case and with `_`, `-` or nothing
38// between its words.
39const SECRET_FIELDS = [
40  'password',
41  'passwd',
42  'secret',
43  'token',
44  'api_key',
45  'x_api_key',
46  'authorization',
47  'access_token',
48  'refresh_token',
49  'id_token',
50  'auth_token',
51  'session_token',
52  'client_secret',
53  'private_key',
54  'aws_secret_access_key',
55  'cookie',
56  'set_cookie',
57  'secret_key',
58  'passphrase',
59];
60const squash = (key: string) => key.toLowerCase().replace(/[_-]/g, '');
61const SECRET_KEYS = new Set(SECRET_FIELDS.map(squash));
62
63// R14. A match must not follow a letter, digit, `_` or `-`, unless that letter ends a JSON
64// escape (`\n`, `\r`, `\t`): MCP results are often JSON text. A private key block matches anywhere.
65const NOT_AFTER = String.raw`(?:(?<![\w-])|(?<=\\[nrt]))`;
66const PRIVATE_KEY =
67  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g;
68const SECRET_PATTERNS = [
69  /sk-ant-[\w-]{20,}/,
70  /sk-[\w-]{20,}/,
71  /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}/,
72  /github_pat_\w{22,}/,
73  /(?:AKIA|ASIA)[0-9A-Z]{16}/,
74  /xox[abpr]-[A-Za-z0-9-]{10,}/,
75  /AIza[\w-]{35}/,
76  /eyJ[\w-]+\.eyJ[\w-]+\.[\w-]+/,
77  /glpat-[\w-]{20,}/,
78  /sk_live_[A-Za-z0-9]{20,}/,
79  /rk_live_[A-Za-z0-9]{20,}/,
80  /whsec_[A-Za-z0-9]{20,}/,
81  /npm_[\w-]{20,}/,
82].map((pattern) => new RegExp(NOT_AFTER + pattern.source, 'g'));
83const BEARER = new RegExp(`${NOT_AFTER}([Bb]earer )[\\w.~+/=-]{8,}`, 'g');
84// R14: a listed field's string value inside JSON text, and inside JSON escaped once more (a JSON
85// string holding JSON; an inner escape is an escaped backslash plus one escape unit, so an escaped
86// quote inside the value does not end it). ponytail: deeper escaping and non-string values are not
87// matched.
88const FIELDS = SECRET_FIELDS.map((name) => name.replaceAll('_', '[_-]?')).join('|');
89const JSON_FIELD = new RegExp(String.raw`("(?:${FIELDS})"\s*:\s*")(?:[^"\\]|\\.)*(")`, 'gi');
90const ESCAPED_FIELD = new RegExp(
91  String.raw`(\\"(?:${FIELDS})\\"\s*:\s*\\")(?:\\\\(?:\\.|[^"\\])|\\[^"\\]|[^"\\])*(\\")`,
92  'gi',
93);
94
95const redactText = (text: string): string =>
96  SECRET_PATTERNS.reduce(
97    (out, pattern) => out.replace(pattern, '[redacted]'),
98    text
99      .replace(JSON_FIELD, '$1[redacted]$2')
100      .replace(ESCAPED_FIELD, '$1[redacted]$2')
101      .replace(PRIVATE_KEY, '[redacted]')
102      .replace(BEARER, '$1[redacted]'),
103  );
104
105/** R14: a deep copy with secret fields and secret-shaped strings replaced by `[redacted]`. */
106export const redact = (value: unknown): unknown => {
107  if (typeof value === 'string') return redactText(value);
108  if (Array.isArray(value)) return value.map(redact);
109  if (value !== null && typeof value === 'object') {
110    return Object.fromEntries(
111      Object.entries(value).map(([key, inner]) => [
112        key,
113        SECRET_KEYS.has(squash(key)) ? '[redacted]' : redact(inner),
114      ]),
115    );
116  }
117  return value;
118};
119
120/** R24: `s`, or its first `max` characters and a marker naming how many were cut. */
121export const cut = (s: string, max: number): string =>
122  s.length <= max ? s : `${s.slice(0, max)}…[cut ${s.length - max} chars]`;
123
124const mapStrings = (value: unknown, fn: (s: string) => string): unknown => {
125  if (typeof value === 'string') return fn(value);
126  if (Array.isArray(value)) return value.map((inner) => mapStrings(inner, fn));
127  if (value !== null && typeof value === 'object') {
128    return Object.fromEntries(
129      Object.entries(value).map(([key, inner]) => [key, mapStrings(inner, fn)]),
130    );
131  }
132  return value;
133};
134
135/** R24: every string inside the (already redacted) arguments cut to 2,000 characters. */
136export const cutArgs = (args: unknown): unknown => mapStrings(args, (s) => cut(s, 2000));
137
138export type Response = {
139  toolNames: string[];
140  complete: boolean;
141  model?: string; // R4: the model that answered, else the one the request named
142  effort?: string | number; // R4: as the request asked; absent for a model without effort
143  /** R51: API-reported input (all three input counts summed) and output, and server tool uses. */
144  usage?: { in: number; out: number; serverTools: number } | null;
145};
146export type LabelCall = { id: string; tool: string; agent: string; response: number | null };
147
148/** R12: each call's next action, read from the next complete response of its agent. */
149export const labelCalls = (
150  responses: Record<string, Response[]>,
151  calls: LabelCall[],
152  isRunning: (agent: string) => boolean,
153): Record<string, NextAction> => {
154  const labels: Record<string, NextAction> = {};
155  for (const call of calls) {
156    const list = responses[call.agent] ?? [];
157    const next =
158      call.response === null
159        ? undefined
160        : list.slice(call.response + 1).find((response) => response.complete);
161    labels[call.id] = !next
162      ? isRunning(call.agent)
163        ? 'pending'
164        : 'aborted'
165      : next.toolNames.length === 0
166        ? 'answered'
167        : next.toolNames.includes(call.tool)
168          ? 'retried'
169          : next.toolNames.includes('AskUserQuestion')
170            ? 'asked-user'
171            : 'other-tool';
172  }
173  return labels;
174};
175
176/**
177 * R51: how much the agent's context grew after response `k`: the next response with usage, its
178 * input minus k's input and k's output. Only when k asked for exactly one tool (any tool, built-in
179 * included) and no server tool, and the growth is not negative (a compaction ran between them).
180 * Clearing old tool results in between shrinks the input without going negative: the figure is
181 * then understated, not dropped.
182 */
183// ponytail: no split for parallel or mixed calls; attribute by chars share if anyone asks.
184export const ctxDelta = (list: Response[], k: number | null): number | undefined => {
185  const at = k === null ? undefined : list[k];
186  if (!at?.usage || at.toolNames.length !== 1 || at.usage.serverTools > 0) return undefined;
187  const next = list.slice(k! + 1).find((response) => response.usage)?.usage;
188  if (!next) return undefined;
189  const delta = next.in - at.usage.in - at.usage.out;
190  return delta >= 0 ? delta : undefined;
191};
192
193const values = (text: string): string[] =>
194  text
195    .split(/[^\w.:/-]+/)
196    .map((token) => token.replace(/[.:/-]+$/, ''))
197    .filter((token) => token.length >= 4 && /\d/.test(token));
198
199/** R13: up to 5 values from the result that the answer names, in result order. */
200export const usedInAnswer = (result: string, answer: string | null): string[] => {
201  if (answer === null) return [];
202  const named = new Set(values(answer));
203  return [...new Set(values(result))].filter((value) => named.has(value)).slice(0, 5);
204};
205
206/** R15: one record cut until its JSON line fits in `max`, largest field first, each field
207 * replaced by a marker. Every pass must shorten the line, or it stops. */
208const shrink = (line: string, max: number): string => {
209  const record = JSON.parse(line) as Record<string, unknown>;
210  record.truncated = true;
211  let out = JSON.stringify(record);
212  while (out.length > max) {
213    const [largest] = Object.keys(record)
214      .filter((key) => key !== 'type' && key !== 'truncated')
215      .map((key) => ({ key, size: JSON.stringify(record[key]).length }))
216      .sort((x, y) => y.size - x.size);
217    if (!largest) break;
218    record[largest.key] = `…[cut ${largest.size} chars]`;
219    const shorter = JSON.stringify(record);
220    if (shorter.length >= out.length) break; // no progress: stop rather than spin
221    out = shorter;
222  }
223  return out;
224};
225
226// ponytail: 1,000,000 chars is at most 4 MiB even at 4 bytes per char; measure bytes
227// (TextEncoder) if part counts ever matter.
228/** R15: JSONL lines packed into parts of at most `maxChars`, never dropping a record. */
229export const toParts = (lines: string[], maxChars = 1_000_000): string[][] => {
230  const parts: string[][] = [];
231  let current: string[] = [];
232  let size = 0;
233  for (const raw of lines) {
234    const line = raw.length > maxChars ? shrink(raw, maxChars) : raw;
235    const added = line.length + (current.length > 0 ? 1 : 0);
236    if (current.length > 0 && size + added > maxChars) {
237      parts.push(current);
238      current = [];
239      size = 0;
240    }
241    size += line.length + (current.length > 0 ? 1 : 0);
242    current.push(line);
243  }
244  if (current.length > 0) parts.push(current);
245  return parts;
246};
247
248/** R11: compact JSON indented (lossless, since it round-trips exactly); anything else as read. */
249export const pretty = (text: string): string => {
250  try {
251    const value: unknown = JSON.parse(text);
252    return JSON.stringify(value) === text ? JSON.stringify(value, null, 2) : text;
253  } catch {
254    return text;
255  }
256};
257
258// The engine unmounts a tree over 100,000 serialized characters. Two fields at 45,000 each,
259// plus the rest of the detail view, stay under it; the budget bounds every path, indented,
260// as stored, or cut.
261const INDENTED_BUDGET = 45_000;
262
263/** The longest prefix of `s` whose JSON string form is at most `budget` characters. */
264const fit = (s: string, budget: number): string => {
265  if (JSON.stringify(s).length <= budget) return s;
266  let lo = 0;
267  let hi = s.length;
268  while (lo < hi) {
269    const mid = Math.ceil((lo + hi) / 2);
270    if (JSON.stringify(s.slice(0, mid)).length <= budget) lo = mid;
271    else hi = mid - 1;
272  }
273  return s.slice(0, lo);
274};
275
276/** R20 and R11: what the detail view draws for one field, never over the serialized budget. */
277export const show = (indented: string, raw: string, full: number): string => {
278  if (full <= 20_000 && JSON.stringify(indented).length <= INDENTED_BUDGET) return indented;
279  const shown = fit(raw.slice(0, 20_000), INDENTED_BUDGET);
280  return shown.length < full ? `${shown}\n${full - shown.length} chars cut` : shown;
281};
282
283/** The engine refuses a Text child over 10,000 characters: cut the text into slices. */
284export const chunks = (text: string, size = 10_000): string[] =>
285  Array.from({ length: Math.max(1, Math.ceil(text.length / size)) }, (_, i) =>
286    text.slice(i * size, (i + 1) * size),
287  );
288
289// v0.3 helpers: the display (R26 to R48) and the paths, JSON and naming checks the hooks share
290// (R10, R19, R37, delta R18). The IDs are in the README index.
291
292/** R32: the tool name without its `mcp__<server>__` prefix. */
293export const toolName = (tool: string): string => {
294  const server = mcpServer(tool);
295  return server === null ? tool : tool.slice(`mcp__${server}__`.length);
296};
297
298/** R32: below 1,000 ms as `812ms`, else seconds to one decimal, rounded half up. */
299export const formatDur = (ms: number): string =>
300  ms < 1000 ? `${Math.round(ms)}ms` : `${(Math.floor(ms / 100 + 0.5) / 10).toFixed(1)}s`;
301
302/** R29: whole seconds, or `<m>m<ss>s` from a minute. */
303export const formatElapsed = (ms: number): string => {
304  const s = Math.max(0, Math.floor(ms / 1000));
305  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`;
306};
307
308/** R48: `text` if it fits `width`, else cut to `width` ending with `…`. */
309export const clip = (text: string, width: number): string =>
310  text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
311
312/** R26: the status entry, or undefined while nothing was called or expanded. */
313export const statusLine = (t: {
314  calls: number;
315  errors: number;
316  tokens: number;
317  skills: number;
318  ctx: number | null;
319}): string | undefined => {
320  if (t.calls === 0 && t.skills === 0) return undefined;
321  const parts = ['telltale'];
322  if (t.calls > 0) parts.push(`${t.calls} MCP`);
323  if (t.errors > 0) parts.push(`${t.errors}✗`);
324  if (t.calls > 0) parts.push(`~${formatTokens(t.tokens)} tok`);
325  if (t.skills > 0) parts.push(plural(t.skills, 'skill'));
326  if (t.ctx !== null) parts.push(`ctx ${t.ctx}%`);
327  return parts.join(' · ');
328};
329
330/** R29: the band row; parts 4 then 3 drop to fit, then the call's name is cut. */
331export const bandRow = (
332  b: {
333    tool: string;
334    elapsedMs: number;
335    more: number;
336    turnCalls: number;
337    turnTokens: number;
338    turnErrors: number;
339  },
340  width: number,
341): string => {
342  const elapsed = formatElapsed(b.elapsedMs);
343  const more = b.more > 0 ? ` +${b.more} running` : '';
344  const turn =
345    b.turnCalls > 0 ? ` · turn: ${b.turnCalls} MCP · ~${formatTokens(b.turnTokens)} tok` : '';
346  const errors = b.turnCalls > 0 && b.turnErrors > 0 ? ` · ${b.turnErrors}✗` : '';
347  const head = (name: string) => `◐ ${name} ${elapsed}${more}`;
348  for (const row of [head(b.tool) + turn + errors, head(b.tool) + turn, head(b.tool)]) {
349    if (row.length <= width) return row;
350  }
351  const room = width - head('').length;
352  return clip(head(clip(b.tool, Math.max(1, room))), width);
353};
354
355export type TableRow = {
356  tool: string; // as drawn: prefix removed, `↳ ` for a subagent's call
357  server: string | null;
358  ms: number;
359  tokens: number;
360  isError: boolean;
361  next: string | null; // the R12 label, null until the turn ends
362};
363export type Table = {
364  header: string;
365  narrow: boolean;
366  // `main` holds TOOL, SERVER, TIME and `~<tok>`; `mark` is ` ✗` or ''; `next` is the
367  // spacing up to the NEXT column and its label ('' when narrow).
368  rows: { main: string; mark: string; next: string }[];
369};
370
371/** R32 (amended): columns two spaces apart, SERVER at most 12, TOOL 4 to 32 and what is left. */
372export const callTable = (rows: TableRow[], width: number): Table => {
373  const narrow = width < 50;
374  const cells = rows.map((r) => ({
375    tool: r.tool,
376    server: clip(r.server ?? '-', 12),
377    time: formatDur(r.ms),
378    tok: `~${formatTokens(r.tokens)}`,
379    mark: r.isError ? ' ✗' : '',
380    next: r.next ?? '…',
381  }));
382  const widest = (header: string, values: string[]) =>
383    Math.max(header.length, ...values.map((v) => v.length));
384  const serverW = widest(
385    'SERVER',
386    cells.map((c) => c.server),
387  );
388  const timeW = widest(
389    'TIME',
390    cells.map((c) => c.time),
391  );
392  const tokW = widest(
393    'TOK',
394    cells.map((c) => c.tok + c.mark),
395  );
396  const nextW = widest(
397    'NEXT',
398    cells.map((c) => c.next),
399  );
400  const others = narrow ? timeW + tokW + 4 : serverW + timeW + tokW + nextW + 8;
401  const toolW = Math.max(
402    4,
403    Math.min(
404      32,
405      widest(
406        'TOOL',
407        cells.map((c) => c.tool),
408      ),
409      width - others,
410    ),
411  );
412  const pad = (s: string, w: number) => clip(s, w).padEnd(w);
413  const lead = (tool: string, server: string, time: string) =>
414    narrow
415      ? `${pad(tool, toolW)}  ${pad(time, timeW)}  `
416      : `${pad(tool, toolW)}  ${pad(server, serverW)}  ${pad(time, timeW)}  `;
417  return {
418    narrow,
419    header: narrow
420      ? `${lead('TOOL', '', 'TIME')}TOK`
421      : `${lead('TOOL', 'SERVER', 'TIME')}${pad('TOK', tokW)}  NEXT`,
422    rows: cells.map((c) => ({
423      main: lead(c.tool, c.server, c.time) + c.tok,
424      mark: c.mark,
425      next: narrow ? '' : `${' '.repeat(tokW - c.tok.length - c.mark.length + 2)}${c.next}`,
426    })),
427  };
428};
429
430/** R41: 20 cells, filled by share of the costliest, rounded half up; at least 1 above zero. */
431export const bar = (tokens: number, max: number): string => {
432  const filled =
433    max <= 0
434      ? 0
435      : Math.min(20, Math.max(tokens > 0 ? 1 : 0, Math.floor((20 * tokens) / max + 0.5)));
436  return '━'.repeat(filled) + '─'.repeat(20 - filled);
437};
438
439/** R41: `part` over `whole` as a percentage to one decimal, rounded half up. */
440export const pct = (part: number, whole: number): string =>
441  (Math.floor((part * 1000) / whole + 0.5) / 10).toFixed(1);
442
443/** R42: a server's share of the session totals, or `never called`. */
444export const usageText = (
445  u: { calls: number; errors: number; tokens: number } | undefined,
446): string => {
447  if (!u || u.calls === 0) return 'never called';
448  const parts = [plural(u.calls, 'call')];
449  if (u.errors > 0) parts.push(`${u.errors}✗`);
450  parts.push(`~${formatTokens(u.tokens)} read`);
451  return parts.join(' · ');
452};
453
454/** R41 (amended): `<server>  <bar>  <tok> tok · <p>% · <usage>`. */
455export const serverHeading = (h: {
456  name: string;
457  pad: number;
458  tokens: number;
459  max: number;
460  window: number | null;
461  usage: string;
462}): string =>
463  `${h.name.padEnd(h.pad)}  ${bar(h.tokens, h.max)}  ${formatTokens(h.tokens)} tok` +
464  `${h.window ? ` · ${pct(h.tokens, h.window)}%` : ''} · ${h.usage}`;
465
466/** R36 (amended): `<tool> · <server> · agent <name or id> · <dur> · <c> chars · ~<tok> tok · <next>`. */
467export const detailHeader = (c: {
468  tool: string;
469  server: string | null;
470  agentId: string | null;
471  agentName?: string; // `<task> (<type>)` from the agent list, when telltale saw it listed
472  ms: number;
473  chars: number;
474  tokens: number;
475  next: string | null;
476}): string =>
477  [
478    c.tool,
479    ...(c.server ? [c.server] : []),
480    // R48: the name is capped so the figures after it survive the row clip.
481    ...(c.agentId ? [`agent ${c.agentName ? clip(c.agentName, 24) : c.agentId}`] : []),
482    formatDur(c.ms),
483    `${c.chars} chars`,
484    `~${formatTokens(c.tokens)} tok`,
485    c.next ?? '…',
486  ].join(' · ');
487
488/** R50: the detail's line on what a call's duration includes, by its permission verdict. */
489export const permissionNote = (decision?: 'allow' | 'ask' | 'deny'): string => {
490  if (decision === undefined) return 'duration includes any permission prompt';
491  return decision === 'ask'
492    ? 'duration includes a permission decision (dialog or classifier)'
493    : 'no permission dialog or classifier in this time';
494};
495
496/** R19: a path that starts at a drive or a root, not at the session's directory. */
497export const isAbsolute = (path: string) => /^(?:[A-Z]:)?[\\/]/i.test(path);
498
499/** `path` with `/` separators and its `.` and `..` segments resolved. */
500const normalize = (path: string): string => {
501  const slashed = path.replaceAll('\\', '/');
502  const parts: string[] = [];
503  for (const part of slashed.split('/')) {
504    if (part === '..' && parts.length > 1) parts.pop();
505    else if (part !== '.' && (part !== '' || parts.length === 0)) parts.push(part);
506  }
507  // A UNC share (`\\host\share`) keeps both of its leading separators.
508  return (slashed.startsWith('//') ? '/' : '') + parts.join('/');
509};
510
511/** R10 (amended): the log folder relative to the start directory when under it, else absolute. */
512export const logsPath = (folder: string, startDir: string): string => {
513  const path = normalize(folder);
514  const base = normalize(startDir).replace(/\/+$/, '');
515  if (base === '') return path; // an unknown start directory: no prefix to strip
516  return path.startsWith(`${base}/`) ? path.slice(base.length + 1) : path;
517};
518
519/** R37: whether `text` parses as JSON. */
520export const isJson = (text: string): boolean => {
521  try {
522    JSON.parse(text);
523    return true;
524  } catch {
525    return false;
526  }
527};
528
529/** delta R18: a skill's group: its plugin, else its source without `Settings`, else `other`. */
530export const skillGroup = (source?: string, plugin?: string): string =>
531  source === 'plugin' ? (plugin ?? 'other') : source ? source.replace(/Settings$/, '') : 'other';
532
533/** R28, R29: a call named as `<server>.<tool>`. */
534export const serverTool = (tool: string): string => `${mcpServer(tool)}.${toolName(tool)}`;
535
types/index.d.ts 74 lines
1export type View = 'calls' | 'detail' | 'inventory';
2export type NextAction =
3  'pending' | 'aborted' | 'answered' | 'retried' | 'asked-user' | 'other-tool';
4export type Call = {
5  id: string; // tool_use_id
6  tool: string;
7  server: string | null; // MCP server, null for built-in tools
8  agentId: string | null;
9  response: number | null; // index of the issuing response within its agent's list
10  ms: number;
11  argsChars: number;
12  textChars: number;
13  isError: boolean;
14  next: NextAction | null; // null until the turn ends
15  turn: number; // R34: the number of the turn file the call's record goes to
16  permission?: 'allow' | 'ask' | 'deny'; // R50: the tool.check verdict beneath telltale; absent when unknown
17};
18// R25: a listed call's text, kept in the mod's memory rather than in `$.state`.
19export type CallDetail = {
20  args: string; // JSON of the arguments, unredacted, first 20,000 chars (R25, R20)
21  text: string; // what Claude read, unredacted, first 20,000 chars
22  used: string[]; // R13, raw values
23};
24export type InventoryRow = {
25  group: string;
26  name: string;
27  tokens: number;
28  measured: boolean;
29  state?: 'loaded' | 'deferred';
30};
31export type Inventory = {
32  rows: InventoryRow[];
33  status: 'idle' | 'measuring' | 'unavailable' | `measure failed: ${string}`;
34  window: number | null; // R41: the context window the breakdown measures against
35  measured: Record<string, number>; // R21, R47: the last `m` figures, by row id
36};
37// R26, R42: counts since session start or the last /clear, kept across a hot reload (R47).
38export type ToolTotals = { calls: number; errors: number; tokens: number };
39export type Totals = {
40  calls: number;
41  errors: number;
42  tokens: number;
43  skills: string[];
44  ctx: number | null; // R26: Claude Code's share of the context window in use
45  tools: Record<string, ToolTotals>; // by full tool name
46};
47// R29: counted calls in flight, by tool_use_id.
48export type Running = Record<string, { tool: string; startedAt: number }>;
49
50declare module 'claude-code' {
51  interface PluginState {
52    telltale: {
53      calls: Call[];
54      dropped: number; // calls evicted past 200 (R25)
55      view: View;
56      selected: string | null;
57      inventory: Inventory;
58      folder: string; // absolute log folder for this session ('' until session start)
59      start: string; // R10: the directory the session started in ('' until session start)
60      warned: boolean; // R16: the write-failure notice was shown this session
61      pending: Record<string, string>; // delta R12: pending call id -> agent id
62      agents: Record<string, string>; // R36: agent id -> `<task> (<type>)`, from the agent list
63      totals: Totals;
64      running: Running;
65      tick: number; // R29: bumped each second while a call runs, so the band redraws
66      turnNo: number; // R34: the last turn number this process took
67      toastedTurn: number; // R28: the turn that already had its toast
68      toastedNames: string[]; // R28: `<server>.<tool>:failed|large` already toasted since /telltale or /clear
69      deferred: { turn: number; text: string; key: string } | null; // R28: a large-result toast held for its turn's end
70      message: string | null; // R39, R40: the detail view's last copy message
71    };
72  }
73}
74