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…

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.
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.
/plugin install telltale@j0hanz-marketplace
To try it from a clone of the marketplace, start Claude Code with --plugin-dir plugins/telltale.
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:
/clear, with Claude Code's share of the context window in use: telltale · 7 MCP · 1✗ · ~12.6k tok · ctx 61%
◐ db.run_query 3s · turn: 2 MCP · ~2.2k tok · 1✗
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.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.
| Key | Where | Action |
|---|---|---|
1 | anywhere | Calls view |
2 | anywhere | Inventory view |
| Up / Down | Calls | move the selection |
| Enter | Calls | open the detail of the selected call |
b | detail | return to the list |
n / p | detail | the next older / newer call |
c / y | detail | copy the arguments / the result text the pane keeps (the first 20,000 characters) |
m | Inventory | measure: ask Claude Code for an exact token count of MCP tools and memory files (skills stay estimates) |
| Esc | anywhere | close the pane |
Set these in the plugin's settings in Claude Code.
| Setting | Default | Meaning |
|---|---|---|
logDir | .claude/telltale | Folder for per-turn logs, relative to the directory the session started in, or an absolute path |
fullPayloads | false | Also log each tool result's full text, redacted, not just the 300-character head and tail |
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 recordOne JSON object per line.
| field | type | meaning |
|---|---|---|
type | "call" | |
tool | string | tool name, e.g. mcp__orders__search or Read |
server | string or null | the <server> in mcp__<server>__<tool>; null for built-in tools |
agentId | string or null | subagent id, null on the main thread |
model | string or null | the 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 |
effort | string, number or null | the thinking effort that response's request asked for; null for a model without effort |
ms | number | wall time, including any permission decision; see permission |
permission | string or null | allow (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 |
permissionRule | string | the settings rule that decided, as written, redacted; only when a rule decided |
args | object | the arguments, redacted; every string cut to 2,000 characters followed by …[cut N chars] |
chars | number | characters of result text Claude read |
estTokens | number | ceil(chars / 4) |
ctxDelta | number | measured 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 |
isError | boolean | result was an error or was denied |
blocks | string[] | content block kinds in the result (e.g. ["text"], ["image"]) |
head, tail | string | first / last 300 characters of the redacted result text |
next | string | what Claude did next: answered, retried (same tool), other-tool, asked-user, aborted, pending (subagent still running when the file was written) |
usedInAnswer | string[] | up to 5 values (at least 4 chars, with a digit) that appear in both the result and the final answer, in result order |
text | string | the full redacted result text; only when fullPayloads is true |
truncated | true | only 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.
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
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 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:
sk-ant- prefix) and other sk- keys of 20 or more charactersghp_, gho_, ghu_, ghs_, ghr_, 36 or more characters) and github_pat_ tokensglpat- prefix, 20 or more characters)sk_live_, rk_live_, whsec_, 20 or more characters)npm_ prefix, 20 or more characters)AKIA or ASIA plus 16 characters)xox followed by a, b, p or r, then a dash)AIza plus 35 characters)eyJ)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.
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.
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.
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.next and usedInAnswer, the permission verdict and rule (R50), and ctxDelta when R51 gives one.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.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./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./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.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.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.[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.hooks/register.tsx 1466 lines1// 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 lines1// 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)}`;
535types/index.d.ts 74 lines1export 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