A mod that shows what Claude Code is doing, live: a timeline of tool calls, subagents, MCP and web calls, and every request to the model with tokens and cache…

A mod for Claude Code that shows what the agent is doing while it does it: every tool call, every subagent, every MCP and web call, and every request to the model, with tokens, cache and time.
It opens as a pane beside the transcript, as a full page in your browser (w in the pane, or /radar-web), and adds a one-line summary to the spinner even when the pane is closed:
Thinking · ⚙ Bash 4.2s · 12 tools · 7 req. · 2 agents…
This is a mod, which means it is code. It runs inside Claude Code with your permissions and sees every prompt and tool call in the session. It sends nothing off your machine and calls no model; the only file it writes is the export: when you ask for it with
/radar-export, or after each activity if you turn that on. Between sessions it keeps your language and settings and, only if you turn insights on, counts per project with no prompt or argument in them. Notifications are off until you turn them on. The browser view, when you open it, is served from127.0.0.1alone, behind a random token. SECURITY.md lists exactly what it calls, andclaude plugin validate .prints the same list from the code.
The radar has two places. The pane in the terminal keeps what you read at a glance or act on right away: the session's numbers, the activities, the suggestions and the settings. The browser view adds what needs room to read: the timeline, the agents, every request with its cache, charts and the machine's memory. Press h on any tab of the pane for a few lines on how to read it.
1 · Home, where the pane opens. The session at a glance: the model and its effort level, how full the context is, the tokens in and out with the share served from the cache, the cost, how much of the 5-hour and weekly plan limits is used, with when each starts over, and when the main session's cache expires. Below, one line per tab, with how much there is once there is something, and what only the browser shows.
Model opus-5-5 · effort high
Context ██████████░░░░░░░░░░░░░░ 42% 84.0k of 200.0k
Tokens 1.2M in (88% from cache) · 41.0k out · US$ 3.10
5h window ███████░░░░░░░░░░░░░░░░░ 31% resets at 15:40
Weekly ████░░░░░░░░░░░░░░░░░░░░ 18% resets Mon 09:00
Cache expires in 52m10s · if it expires: ~84.0k tok rewritten
Where to look
2 Activities 6 activities · each prompt and the tools it called
3 Insights 2 suggestions for this project's setup
4 Config notifications, insights, auto export, badge, language
w Browser 38 calls, 24 requests · timeline, agents, tokens, memory
The effort level comes from the /config row that holds it; when your build lists none, the line shows the model alone. Accounts without plan limits (an API key) see a line saying so. The cache line counts from the main session's last request that read or wrote the cache, with the TTL the request reported; when your build does not pass the TTL on to mods, it shows the time left under both 5 minutes and 1 hour.
2 · Activities. One line per prompt you sent: its first 120 characters, how long it took, how many tools it called and how many failed. The newest is open; Tab or the arrows move between them and Enter opens another. An open activity lists how many times it called each tool, its model requests and tokens, and every call in order, its subagents' calls indented, each with when it started (counted from the prompt), its argument, how long it took and whether it failed.
▾ ✓ #1 fix the login bug 38.2s · 6 tools · 1 failure
Bash ×2 · Agent ×1 · Grep ×1 · github·search_issues ×1 · WebFetch ×1
✦ 4 req. · 20.5k in / 1.2k out
+2.1s ⚙ Bash npm test 11.4s
+13.6s ◆ Agent map the auth module 38.0s
+13.9s ↳⚙ Grep login 0.3s
3 · Insights. Off until you turn it on in Config. Fixed rules read what the radar recorded and suggest changes to how Claude Code is set up, most severe first, each with the numbers behind it:
| Rule | What it looks for | What it suggests |
|---|---|---|
| Cache expired | A pause longer than the cache TTL, then the prompt sent again uncached | The cache warning; whether a 1 hour TTL would have paid off here |
| Model switch | The same loop changing model and losing its cache | Switching between activities instead |
| Denials | The same command denied twice or more | A permissions.allow rule for read-only commands, a permissions.deny rule for destructive ones (never allow) |
| Failures | The same command failing three times or more, this session and earlier ones | The right command in CLAUDE.md, or a hook |
| Repeated command, repeated fetch | The same command 5 times, the same URL 3 times | A script, skill or slash command; a local copy of the docs |
| Slow tool | A tool whose 95th percentile is over a minute | Running it in the background |
| Subagent model | A subagent that only read files, on a model other than Haiku | The Explore type or model: haiku |
| Context | Context over 80% | /compact, or loading less every session |
| First request | A first request over 30k tokens, or well over this project's median | Reviewing CLAUDE.md, MCP servers and skills |
| Unused MCP, unreliable MCP | A server in .mcp.json not called in 5 sessions; one that fails or is slow | Disabling or fixing it |
Each finding has a mark applied button. Press it once you have made the change, and the tab compares the rule's number per session before and after, so you can see whether it paid off. The browser view shows the same findings and comparisons, read-only: marking is done here.
4 · Config. Everything that notifies or keeps data starts off. Move with Tab or the arrows, and press Enter to change a line:
| Setting | What it does |
|---|---|
| Notify when an activity ends | A notification when an activity that took at least 15 s, 30 s, 1 min or 2 min ends: its number, duration, tools, failures and when the cache expires. Never the prompt |
| Warn before the cache expires | One notification per cache entry, 30 s to 5 min before the main session's cache expires, while nothing is running: how long is left and how many tokens the next request would rewrite. An unknown TTL counts as 5 minutes, and the text says so |
| Insights, with per-project history | Turns on the Insights tab and keeps counts per project between sessions (below) |
| Suggest the AI review | Shows the AI review in the Insights tab |
| Save the export automatically when an activity ends | Writes the export after each activity, without asking: one session-radar-<start>.json per conversation in .claude/session-radar/ of the folder the session started in, rewritten each time with the whole conversation, ready for the AI review. That folder ignores itself in git |
| Always show this session's badge | Keeps the session's badge under the prompt even with the browser view closed. See Browser view |
| Delete the stored history | Deletes every project's history |
| Language | auto, Portuguese or English |
Notifications go through Claude Code's own notification channel, the one your preferredNotifChannel setting names, and your Notification hooks see them. Where that channel shows no text (no channel under auto, as in Windows Terminal, or only the bell), the same text shows as a toast in the corner of the transcript instead; a Notification hook that answers for it keeps it out. While the browser view is open, it shows them too, as a toast on the page, and as your system's own notification while its tab is in the background, once you press Turn on browser notifications in its footer and allow it.
History. With insights on, the radar keeps, per project folder, counts for each of the last 30 sessions in at most 20 projects: activities, calls, failures, denials, tokens, cache, the size of the first request, time and failures per tool, which MCP servers were called, and the first two words of each failing command. No prompt and no argument. It also reads the server names from the project's .mcp.json, and nothing else from it. MCP servers you added for your user, not the project, are in ~/.claude.json, which a mod cannot read, so the unused-MCP rule does not see them.
The interface is in English or Brazilian Portuguese. Pick one with /radar-lang pt, /radar-lang en or /radar-lang auto, or with the Language line in Config. auto, the default, follows your system language. The choice is kept for your next sessions, and the browser view follows it.
Press w in the pane or run /radar-web (Browser view says how it is kept local). Keys 1 to 8 switch view; the first three match the pane's. The picker in the header chooses the session the page shows, or all of them (see Browser view).
1 · Home. The session's cards (model and effort, context, tokens, cost, plan limits) and the machine's memory: how much is in use, of how much, and a chart of the last 10 minutes. Below, calls by kind, the duration of each activity (press a bar to open it in Activities) and the time per tool. The memory is read by the browser view's own server, every 2 seconds, from the operating system's totals: no process list and no command. macOS counts cached memory as used, so free memory looks lower there than it is.
2 · Activities and 3 · Insights. The same as in the pane, with room: filters, search and every call of an activity; the findings with their hints, what you marked as applied and the comparisons since.
4 · Timeline. A Gantt-style view: each row is one thing Claude did, placed where it started and as wide as it took, on a time axis you can narrow to the last 15, 5 or 1 minute. A subagent's own calls sit indented under the call that started it. A running call is marked at its edge; red failed or was denied. Press a row for every detail of the call.
5 · Agents. The tree of the main session and its subagents: status, duration, tool calls, model requests, tokens in and out, and the last things each one did. Press one for its calls.
6 · Tokens. How full the context is, cost, plan limits, how much input came from the prompt cache, how long each loop's cache has left, a chart of the prompt per request split into cache read, cache written and new input, with the output below, and a table of every request. What the columns mean explains them:
| Column | What it is |
|---|---|
New | Input tokens the cache did not serve, billed at the full input price. Small on a healthy request: only what changed since the last one. |
Cache↓ | Input tokens read from the prompt cache, far cheaper than new input. Most of a long conversation should land here. |
Cache↑ | Input tokens written to the cache. They cost more than new input once, then the next request reads them cheaply. |
Prompt | New + Cache↓ + Cache↑: everything the request sent. It grows with the conversation. |
Hit | Cache↓ ÷ Prompt. A highlighted row sent most of a large prompt uncached after the loop's first request: the cache was probably lost, because it expired, the model changed, or the start of the prompt changed. |
Cache lifetime. For each loop: the TTL of what was cached, when the cache was last used, how long it has left, and how many tokens the next request rewrites if it expires. The API restarts an entry's lifetime every time a request reads it, so the countdown runs from the last request. The TTL comes from the request itself, which says how much it wrote for 5 minutes and how much for 1 hour.
7 · Hooks. Every hook event Claude Code raises (PreToolUse, PostToolUse, Stop, SessionStart, Notification and the rest), whether or not you configured a hook for it: when it fired, the tool or occasion it is about, its activity and agent, how long the hooks took, and what they answered. allowed, asked and denied are a PreToolUse hook's decision, blocked a hook that blocked the event, +N context text a hook handed to the model, no action an event no hook answered. Filter by event, status or text; press one for its reason and the context it added. Each activity in Activities lists its own hooks too. Where an organization manages Claude Code, hook events reach only the plugins its managed settings list, so there this view stays empty unless Session Radar is one of them.
8 · Q&A. Answers to the common questions about the radar and the page, filtered by the search.
Without Node, or without a browser on the same machine (a plain SSH session, some containers), the timeline, the agents and the tokens are still in the export (/radar-export).
| You need | Check |
|---|---|
| Claude Code 2.1.287 or later, where mods exist | claude --version |
| A terminal, or the Code tab of the Desktop app, to see the pane | see Where it draws |
For the browser view only: Node.js 20 or later on the PATH | node --version |
This repository is its own marketplace. Two steps, in this order, because the install resolves the plugin against marketplaces you have already added:
/plugin marketplace add NeuralSeam/ns-session-radar
/plugin install session-radar@ns-session-radar
If the install says Run /reload-plugins to activate., do that. Then check:
/plugin shows a line such as 1 mod active · session-radar. A mod that is installed but not in that line is not running; Troubleshooting says why./radar opens the pane.To try it without installing, clone this repository and start Claude Code with claude --plugin-dir ./ns-session-radar.
| Command | What it does | ||
|---|---|---|---|
/radar | Opens or closes the pane. Works while Claude is working. | ||
/radar-export | Writes everything recorded so far to .claude/session-radar/session-radar-<time>.json in the folder the session started in | ||
| `/radar-lang pt\ | en\ | auto` | Sets the language and keeps it for your next sessions; with no argument, says which is set |
/radar-web | Opens the radar in your browser (see Browser view); /radar-web stop ends it | ||
/radar-config | Opens the pane on the Config tab | ||
/session-radar:radar-review | The AI review of an export. Uses tokens |
Inside the pane:
| Key | What it does |
|---|---|
1 to 4 | Switch tab: Home, Activities, Insights, Config |
Tab, ↑ ↓, Enter | In Activities, move between activities and open one |
h | Show or hide how to read the tab |
w | Open the browser view, same as /radar-web |
e | Export, same as /radar-export |
x | Clear what has been recorded: press it twice within 3 seconds |
d | Move the radar below the transcript, or back beside it |
Esc | Close the pane |
Beside or below, as DevTools docks: d, or Radar position in Config, kept for your next sessions. Beside, the radar is a pane, which Claude Code docks next to the transcript in fullscreen from 110 columns, and seats above the prompt otherwise. Below, it takes the band above the prompt, compact: tabs and actions on one row when they fit, Home and Config in two columns on a wide terminal, no notes or blank lines and no browser address (w still opens it), in a rounded frame of the badge's color that sets it apart from the transcript (from 8 rows; under that the rows go to the radar). The band takes the keys after ctrl+x tab or a click, q closes it, and a survey from Claude Code has the band first. A mod cannot place a pane itself, so there is no left side.
The radar starts over on /clear, /resume and /branch, because those start a different conversation. What it recorded is not kept between sessions unless you export it, or, with insights on, as the counts of the history.
The rules in the Insights tab find patterns and measure them; they do not write your CLAUDE.md. The plugin also ships a skill for that, /session-radar:radar-review. Export first (e), or turn on Save the export automatically in Config, then run it. Claude, in your session and with your permissions, reads the newest .claude/session-radar/session-radar-*.json, your CLAUDE.md, the permissions, model and hooks of your settings, and .mcp.json, and proposes changes as diffs, each with the numbers behind it and the radar metric that should move. It also does what fixed rules cannot: it groups your prompts into recurring tasks worth a skill, spots where CLAUDE.md and what happened disagree, and gives its guess, with its confidence, of why a command keeps failing. It applies nothing without asking, one change at a time.
The review uses tokens, in the current session. The mod itself still calls no model: the skill is instructions for Claude, run only when you call it.
Claude Code cannot draw a web page inside the terminal: a mod draws with the terminal's own elements (boxes, text, buttons, links). So the richer view opens in your browser, fed by the same data the pane shows.
Press w in the pane (/radar opens it with the focus), or run /radar-web. The mod starts a small server, web/server.mjs (Node, no dependencies), and your default browser opens on it. The reply and the pane beside the transcript also show the address as a link, for when no browser can be opened.
Below the navigation the page shows one ad, marked as such: today Neural Seam's own, for Neural Seam Premium. It is part of the page, like the rest: nothing is loaded to show it and nobody is told it was shown; only clicking it opens neuralseam.cloud in a new tab.
The page has eight views, described under In the browser: Home with the machine's memory, Activities and Insights as in the pane, and Timeline, Agents, Tokens, Hooks and Q&A, which only the browser shows. Config stays in the pane, and so does marking a finding as applied: the page only reads. It adds a search box, filters by kind, status, agent and time window, sortable tables, charts and a panel with every detail of a call or subagent. Keys: 1 to 8 switch view, / searches, Esc closes the panel.
Several sessions at once. Each session has a badge: a mark and a four-letter code, such as ▲ K7QF, in one of four colours, worked out from the session's id, so it stays the same when the mod reloads and differs from one session to the next. While the browser view runs, the badge shows under the prompt of the session's terminal (▲ radar K7QF), and the same badge is on the page:
▲ K7QF · my-app · Session Radar, and its icon, the mark in the session's colour;✕ K7QF · ended and the icon grey, so you know which tabs to close.The pane's status row starts with the badge too. To keep it under the prompt even with the browser view closed, turn on Always show this session's badge in Config.
All your sessions on one page. Every session of yours on the machine shares one server. Run /radar-web in any of them: the others find the page on their own within about 5 seconds, and their badges show under their prompts. The picker in the header lists them by name, the title Claude Code gives the session (the one set with /rename, or the one it made up), else the project folder:
All sessions, and Home shows a card per session, while Activities and Hooks merge them, naming each row's session. Timeline, Agents, Tokens and Insights ask you to pick one.A session that closes, or sends nothing for 20 seconds, is marked ended and stays on the page for 30 minutes. When the session whose terminal started the server closes, another session starts it again on the same address, and the open page carries on. /radar-web stop ends the page for every session, and none starts it again until someone runs /radar-web.
How it is kept local:
127.0.0.1 only, on a port the system picks, and refuses any request whose Host or Origin is not that address.Content-Security-Policy of 'self').session-radar/hub.json under your Claude Code configuration directory, readable by your user alone, so your other sessions can find it./radar-web stop, and by itself 30 seconds after no session sends any more.The w key works while the pane has the focus, not while you type at the prompt: a mod cannot take a plain letter from the prompt. If Node is missing, /radar-web says so and everything else keeps working.
| Where you run Claude Code | Recording | Pane and spinner |
|---|---|---|
claude in a terminal | yes | yes |
| Code tab of the Desktop app | yes | yes |
| VS Code extension's chat panel | yes | no |
claude -p and the Agent SDK | yes | no |
| Cloud sessions | only if the plugin reaches the session | no |
Where nothing draws, /radar-export still gives you the data.
In a narrow terminal the pane sits above the prompt instead of beside the transcript.
| Claude Code event | What the radar takes from it |
|---|---|
tool.call | Tool name, its main argument (one line, at most 120 characters), duration, failed or denied. Agent/Task count as subagents, mcp__* as MCP, WebFetch/WebSearch as web. |
agent.spawn | That a subagent started, and its type |
turn.step | One request to the model: model, input and output tokens, cache read and written, and how much was written for 5 minutes and for 1 hour when the build passes it on |
turn.start, turn.complete | Where each turn starts and ends, the first 120 characters of its prompt (one line), and when a subagent finishes |
$.session.usage() | Context, cost and plan limits; with insights on, the highest context use of the session |
.mcp.json | With insights on, the names of the project's MCP servers, and nothing else |
Some fields on agent.spawn, and the agentId on tool events, are not fully described in the public documentation. The mod reads the likely names and, when none is there, links a subagent to the Agent call that is open at the time. If your build names them differently, a subagent's calls show at the top level instead of indented; nothing else breaks. The exact names for your version are in .claude-plugin/types/claude-code/index.d.ts, which Claude Code writes when you load the mod with --plugin-dir.
/plugin marketplace update
/reload-plugins
/plugin uninstall session-radar@ns-session-radar
To keep it installed but stop it, /plugin disable session-radar@ns-session-radar. Starting Claude Code with --safe-mode, or setting "disableAllHooks": true, also stops it.
| Symptom | Likely cause | Fix |
|---|
| /radar is not a command | Claude Code older than 2.1.287, or the plugin is not loaded | claude --version; then /reload-plugins
hooks/register.js 2221 lines1// Copyright 2026 Neural Seam
2// SPDX-License-Identifier: Apache-2.0
3
4// session-radar: a live view of what Claude Code is doing.
5//
6// Collected while Claude works:
7// ⚙ tool calls (Bash, Read, Edit, Grep, ...)
8// ⇄ MCP tool calls (mcp__server__tool)
9// ◎ web access (WebFetch, WebSearch)
10// ◆ subagents (the Agent/Task tool plus the agent.spawn event)
11// ✦ requests to the model (turn.step), with tokens and cache
12//
13// Shown in a pane with seven tabs, opening on Home (/radar), as a live summary beside the spinner,
14// and, on request (/radar-web), as a page in the browser served from this machine's loopback by web/server.mjs.
15// Nothing leaves the machine: no remote network, no model calls. The only writes are /radar-export,
16// the same file saved after each activity when the person turns that on, and, in the mod's own
17// store, the language, the settings and, when insights are on, per-project counts. Notifications,
18// when turned on, go to the person's own notification channel.
19
20import { analyze, summarize as sessionRecord, upsertHistory, compareApplied, TTL_MS } from './insights.js'
21
22const PANE = 'session-radar'
23const MAX_SPANS = 500
24const MAX_TURNS = 200
25const MAX_HOOKS = 500
26const MAX_LABEL = 120
27const GENERATOR = 'session-radar by Neural Seam (https://github.com/NeuralSeam/ns-session-radar)'
28// The release, shown in the credit line and on the page. It must match .claude-plugin/plugin.json;
29// npm test checks it
30const VERSION = '0.8.0'
31const LANGS = ['auto', 'pt', 'en']
32const WEB_PUSH_MS = 500
33const WEB_BEAT_MS = 5000
34const WEB_START_MS = 10_000
35// How often a session without the browser view looks for one another session started, and how many
36// failed sends in a row make it look for the server again
37const HUB_JOIN_MS = 5000
38const HUB_FAILS = 2
39const AI_REVIEW = '/session-radar:radar-review'
40const EFFORT_MS = 5000
41const CLEAR_CONFIRM_MS = 3000
42const SAVE_MS = 2000
43// The band below the transcript lays Home out in two columns from this width
44const HOME_COLUMNS_MIN = 130
45// Fewer rows than this and the band drops its frame, to keep them for the radar
46const BAND_FRAME_MIN = 8
47
48// Settings, kept in the store under `config`. Everything that notifies or keeps more data starts off.
49const DEFAULTS = { notifyDone: false, notifyDoneMin: 30, notifyCache: false, notifyCacheLead: 60, insights: false, aiHint: false, autoExport: false, badge: false, position: 'side' }
50
51// Each session's badge: a mark and a four-letter code, the same under the terminal's prompt, in the
52// pane and in the browser tab, so parallel sessions are told apart. No 0/O, 1/I/L or U.
53const BADGE_CHARS = '23456789ABCDEFGHJKMNPQRSTVWXYZ'
54const BADGE_MARKS = ['●', '■', '▲', '★']
55const BADGE_COLORS = ['cyan', 'magenta', 'blue', 'green']
56const CHOICES = { notifyDoneMin: [15, 30, 60, 120], notifyCacheLead: [30, 60, 120, 300], position: ['side', 'bottom'] }
57
58// ---------------------------------------------------------------------------
59// Strings. The language is the one picked with /radar-lang or the `l` key; on `auto`, Brazilian
60// Portuguese when the system locale is pt and English otherwise.
61// ---------------------------------------------------------------------------
62
63const STRINGS = {
64 en: {
65 tabHome: 'Home', tabActivities: 'Activities',
66 export: 'Export', clear: 'Clear', clearConfirm: 'x again to clear', help: 'Help', language: 'Language',
67 running: 'running', denied: 'denied',
68 emptyActivities: 'No activity yet. Each prompt you send becomes one activity here, with every tool it called.',
69 activitiesHint: 'Tab or ↑↓ to move, Enter to open or close an activity',
70 continuation: '(no prompt: Claude continued on its own)',
71 noTools: 'No tools called in this activity.',
72 earlier: (n) => `… ${n} earlier calls of this activity are no longer in memory`,
73 more: (n) => `… ${n} more above`,
74 tool: 'tool', tools: 'tools', req: 'req.', inTok: 'in', outTok: 'out',
75 activity: 'activity', activities: 'activities',
76 context: 'Context', of: 'of', cache: 'Cache',
77 expiresIn: (d) => `expires in ${d}`,
78 expired: (d) => `expired ${d} ago`,
79 ifTtl: (a, b) => `${a} left if TTL 5m, ${b} if 1h`,
80 recache: (n) => `if it expires: ~${n} tok rewritten`,
81 fail: 'failure', fails: 'failures',
82 spinTools: 'tools', spinReq: 'req.', spinAgent: 'agent', spinAgents: 'agents',
83 cmdRadar: 'Open or close the session radar pane (tools, agents, model requests)',
84 cmdExport: 'Write everything the radar recorded to a JSON file',
85 cmdLang: 'Set the radar language: pt, en or auto (system language)',
86 langNow: 'Radar language:',
87 langAuto: 'auto (system language)',
88 langUsage: 'Use /radar-lang pt, /radar-lang en or /radar-lang auto.',
89 exported: 'Radar exported to', exportFailed: 'Could not export the radar:', exportNone: 'Could not export the radar.',
90 credit: `Session Radar · by Neural Seam · neuralseam.cloud · v${VERSION}`,
91 web: 'Browser',
92 cmdWeb: 'Open the radar in the browser, served from this machine only; /radar-web stop ends it',
93 webOpened: (url, b) => `Radar web view ${b}: ${url}`,
94 webStopped: 'Radar web view stopped.',
95 webIdle: 'The radar web view is not running.',
96 webFailed: 'Could not start the radar web view. It needs Node.js 20 or later on the PATH (`node`).',
97 webUsage: 'Use /radar-web to open it or /radar-web stop to end it.',
98 tabInsights: 'Insights', tabConfig: 'Config',
99 homeTitle: 'Session Radar · what Claude Code is doing in this session',
100 homeModel: 'Model', homeEffort: 'effort', homeTokens: 'Tokens',
101 homeTokensLine: (inp, hit, out) => `${inp} in (${hit}% from cache) · ${out} out`,
102 noLimits: 'Plan limits: not reported for this account',
103 limitName: { five_hour: '5h window', seven_day: 'Weekly', spend_limit: 'Spend limit' },
104 resetsAt: (t) => `resets at ${t}`,
105 resetsOn: (d, t) => `resets ${d} ${t}`,
106 weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
107 whereToLook: 'Where to look',
108 guide: {
109 activities: (n) => (n ? `${plural(n, 'activity', 'activities')} · ` : '') + 'each prompt and the tools it called',
110 insights: (on, n) => (on ? `${plural(n, 'suggestion', 'suggestions')} for this project's setup`
111 : "suggestions for this project's setup · off: turn on in 4"),
112 config: 'notifications, insights, auto export, badge, position, language',
113 web: (calls, api) => (calls || api ? `${plural(calls, 'call', 'calls')}, ${plural(api, 'request', 'requests')} · ` : '') +
114 'timeline, agents, tokens, memory',
115 },
116 helpText: {
117 home: 'The session at a glance: model, context, tokens, plan limits and the cache, straight from Claude ' +
118 'Code. Below, what each tab shows; w opens the browser view for the timeline, agents, tokens and ' +
119 'memory. Switch tabs with 1 to 4; d moves the radar beside the transcript or below; h hides this help.',
120 activities: 'Each prompt you send is one activity. Open one with Enter to see every tool it called, in ' +
121 "order, its subagents' included, with when it started and how long it took.",
122 insights: "Fixed rules read this session and the project's history and suggest setup changes. Once " +
123 'you apply one, press mark applied: the tab compares the sessions before and after.',
124 config: 'Enter or a click changes a line. Everything that notifies or keeps data starts off.',
125
126 },
127 cmdConfig: 'Open the radar settings: notifications, insights, language',
128 notifyDone: (n, dur, calls, fails, until) => `#${n} done · ${dur} · ${calls} tools` +
129 (fails ? ` · ${plural(fails, 'failure', 'failures')}` : '') + (until ? ` · cache until ${until}` : ''),
130 notifyCache: (left, tok, assumed) => `Main session cache expires in ${left} · ~${tok} tok would be rewritten` +
131 (assumed ? ' (TTL assumed 5m)' : ''),
132 cfgTitle: 'Settings · Enter or click to change; kept for your next sessions',
133 cfgNotifyDone: 'Notify when an activity ends',
134 cfgNotifyDoneMin: (s) => `only if it took at least ${fmtDur(s * 1000)}`,
135 cfgNotifyCache: 'Warn before the main session cache expires',
136 cfgNotifyCacheLead: (s) => `this long before: ${fmtDur(s * 1000)}`,
137 cfgInsights: 'Insights, with per-project history',
138 cfgAiHint: `Suggest the AI review (${AI_REVIEW})`,
139 cfgClearHistory: 'Delete the stored history',
140 cfgAutoExport: 'Save the export automatically when an activity ends',
141 cfgAutoExportNote: 'One file per session in this folder, for the AI review. Do not commit it.',
142 cfgBadge: (b) => `Always show this session's badge (${b}) under the prompt`,
143 cfgBadgeNote: 'It also shows there while the browser view runs, to tell its tab apart.',
144 cfgPosition: 'Radar position',
145 posSide: 'side (beside the transcript, in fullscreen)',
146 posBottom: 'below (above the prompt, compact)',
147 cfgPositionNote: 'd in the radar switches too. Under 110 columns Claude Code places the side pane below.',
148 dockBottom: 'Dock below', dockSide: 'Dock to side', close: 'Close',
149 bandKeys: 'ctrl+x tab for the keys',
150 cfgHistoryCleared: 'History deleted.',
151 cfgNotifyNote: 'Notifications use your Claude Code notification channel, a toast where it has none, and the browser view. Never a prompt.',
152 cfgInsightsNote: 'History keeps counts per project between sessions: no prompts, no arguments.',
153 insightsOff: 'Insights are off. Turn them on in Config (4): they read this session and keep per-project counts.',
154 noFindings: 'Nothing to suggest for this session yet.',
155 historyLine: (n) => `History: ${plural(n, 'earlier session', 'earlier sessions')} of this project`,
156 markApplied: 'mark applied', markedApplied: 'applied ✓',
157 moreInExport: (n) => `… ${n} more: the export (e) has them all`,
158 trends: 'Since the changes you marked as applied',
159 trendLine: (name, before, after, nb, na) => `${name}: ${before} → ${after ?? '…'} (${nb} → ${na} sessions)`,
160 aiHint: `AI review: e to export, then ${AI_REVIEW} (uses this session's tokens)`,
161 unit: { tokTurn: 'tok/turn', countTurn: '/turn', msTurn: '/turn', pct: '%', tok: 'tok' },
162 ins: {
163 'cache-expired': (d) => [
164 `Cache expired ${d.count}× after a pause (longest ${fmtDur(d.longestGap)}): ~${fmtTok(d.rewritten)} tok resent, ~${fmtTok(d.waste)} tok-eq lost`,
165 d.oneHourNet == null ? 'Turn on the cache warning in Config to resume before it expires'
166 : d.oneHourNet > 0 ? `A 1h cache TTL would have saved ~${fmtTok(d.oneHourNet)} tok-eq here; or turn on the cache warning`
167 : 'A 1h TTL would not pay off here (its writes cost 2×); turn on the cache warning instead',
168 ],
169 'cache-model-switch': (d) => [
170 `A model switch lost the cache ${d.count}× (${d.from} → ${d.to}): ~${fmtTok(d.rewritten)} tok resent`,
171 'Each model has its own cache: switch between activities, not in the middle of one',
172 ],
173 'denied-repeated': (d) => [
174 `${d.permission} denied ${d.count}×`,
175 d.safety === 'never' ? 'Claude keeps asking: add it to permissions.deny, or say in CLAUDE.md not to run it'
176 : d.safety === 'safe' ? 'Read-only: a candidate for permissions.allow in .claude/settings.json'
177 : 'If it is safe in this project, add it to permissions.allow; if not, to permissions.deny',
178 ],
179 'failing-repeated': (d) => [
180 `${trunc(d.key, 50)} failed ${d.count}×` + (d.sessions > 1 ? ` in ${d.sessions} sessions` : ''),
181 'Write the right command in CLAUDE.md, or check it with a hook',
182 ],
183 'repeated-command': (d) => [
184 `"${trunc(d.label, 40)}" ran ${d.count}× (${fmtDur(d.ms)})`,
185 'A script, skill or slash command could do it in one step',
186 ],
187 'web-repeat': (d) => [`${trunc(d.url, 50)} fetched ${d.count}×`, 'Keep a local copy of those docs, or use a docs MCP'],
188 'slow-tool': (d) => [`${d.name}: p95 ${fmtDur(d.p95)} over ${d.count} calls`, 'Run long commands in the background, or make them faster'],
189 'subagent-model': (d) => [
190 `Subagent "${trunc(d.label, 30)}" only read files, on ${d.model} (${fmtTok(d.tokIn)} tok)`,
191 'Read-only searches fit the Explore type or model: haiku',
192 ],
193 'context-pressure': (d) => [`Context reached ${d.pct}%`, 'Use /compact between activities, or trim what is always loaded'],
194 'fixed-overhead': (d) => [
195 `The first request already sends ${fmtTok(d.tokens)} tok` + (d.median ? ` (median ${fmtTok(d.median)})` : ''),
196 'System prompt, tools, MCP and CLAUDE.md are paid every session: review what is loaded',
197 ],
198 'mcp-unused': (d) => [
199 `MCP not used in ${d.sessions} sessions: ${d.servers.join(', ')}`,
200 'Their tool definitions still take context: disable them in .mcp.json',
201 ],
202 'mcp-unreliable': (d) => [
203 `MCP ${d.server}: ${d.fails} of ${d.count} calls failed, p95 ${fmtDur(d.p95)}`,
204 'Check the server, or disable it if it does not help',
205 ],
206 },
207 ruleName: {
208 'cache-expired': 'Cache expired', 'cache-model-switch': 'Model switch', 'denied-repeated': 'Denials',
209 'failing-repeated': 'Failures', 'repeated-command': 'Repeated command', 'web-repeat': 'Repeated fetch',
210 'slow-tool': 'Slow tool', 'subagent-model': 'Subagent model', 'context-pressure': 'Context',
211 'fixed-overhead': 'First request', 'mcp-unused': 'Unused MCP', 'mcp-unreliable': 'Unreliable MCP',
212 },
213 },
214 pt: {
215 tabHome: 'Início', tabActivities: 'Atividades',
216 export: 'Exportar', clear: 'Limpar', clearConfirm: 'x de novo para limpar', help: 'Ajuda', language: 'Idioma',
217 running: 'rodando', denied: 'negado',
218 emptyActivities: 'Nenhuma atividade ainda. Cada prompt que você envia vira uma atividade aqui, com todas as ferramentas que ela chamou.',
219 activitiesHint: 'Tab ou ↑↓ para navegar, Enter abre ou fecha uma atividade',
220 continuation: '(sem prompt: o Claude continuou sozinho)',
221 noTools: 'Nenhuma ferramenta chamada nesta atividade.',
222 earlier: (n) => `… ${n} chamadas anteriores desta atividade já saíram da memória`,
223 more: (n) => `… mais ${n} acima`,
224 tool: 'ferramenta', tools: 'ferramentas', req: 'req.', inTok: 'entrada', outTok: 'saída',
225 activity: 'atividade', activities: 'atividades',
226 context: 'Contexto', of: 'de', cache: 'Cache',
227 expiresIn: (d) => `expira em ${d}`,
228 expired: (d) => `expirou há ${d}`,
229 ifTtl: (a, b) => `restam ${a} se TTL 5m, ${b} se 1h`,
230 recache: (n) => `se expirar: ~${n} tok regravados`,
231 fail: 'falha', fails: 'falhas',
232 spinTools: 'ferr.', spinReq: 'req.', spinAgent: 'agente', spinAgents: 'agentes',
233 cmdRadar: 'Abre ou fecha o painel do radar (ferramentas, agentes, requisições ao modelo)',
234 cmdExport: 'Grava tudo o que o radar registrou num arquivo JSON',
235 cmdLang: 'Define o idioma do radar: pt, en ou auto (idioma do sistema)',
236 langNow: 'Idioma do radar:',
237 langAuto: 'auto (idioma do sistema)',
238 langUsage: 'Use /radar-lang pt, /radar-lang en ou /radar-lang auto.',
239 exported: 'Radar exportado para', exportFailed: 'Não foi possível exportar o radar:', exportNone: 'Não foi possível exportar o radar.',
240 credit: `Session Radar · por Neural Seam · neuralseam.cloud · v${VERSION}`,
241 web: 'Navegador',
242 cmdWeb: 'Abre o radar no navegador, servido só nesta máquina; /radar-web stop encerra',
243 webOpened: (url, b) => `Radar no navegador ${b}: ${url}`,
244 webStopped: 'Radar no navegador encerrado.',
245 webIdle: 'O radar no navegador não está rodando.',
246 webFailed: 'Não foi possível iniciar o radar no navegador. Ele precisa do Node.js 20 ou mais novo no PATH (`node`).',
247 webUsage: 'Use /radar-web para abrir ou /radar-web stop para encerrar.',
248 tabInsights: 'Insights', tabConfig: 'Config',
249 homeTitle: 'Session Radar · o que o Claude Code está fazendo nesta sessão',
250 homeModel: 'Modelo', homeEffort: 'esforço', homeTokens: 'Tokens',
251 homeTokensLine: (inp, hit, out) => `${inp} entrada (${hit}% do cache) · ${out} saída`,
252 noLimits: 'Limites do plano: não informados nesta conta',
253 limitName: { five_hour: 'Janela 5h', seven_day: 'Semanal', spend_limit: 'Limite de gasto' },
254 resetsAt: (t) => `renova às ${t}`,
255 resetsOn: (d, t) => `renova ${d} ${t}`,
256 weekdays: ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb'],
257 whereToLook: 'Onde olhar',
258 guide: {
259 activities: (n) => (n ? `${plural(n, 'atividade', 'atividades')} · ` : '') + 'cada prompt e as ferramentas que ele chamou',
260 insights: (on, n) => (on ? `${plural(n, 'sugestão', 'sugestões')} para a configuração do projeto`
261 : 'sugestões para a configuração do projeto · desligado: ligue em 4'),
262 config: 'avisos, insights, export automático, crachá, posição, idioma',
263 web: (calls, api) => (calls || api ? `${plural(calls, 'chamada', 'chamadas')}, ${api} req. · ` : '') +
264 'linha do tempo, agentes, tokens, memória',
265 },
266 helpText: {
267 home: 'A sessão num relance: modelo, contexto, tokens, limites do plano e o cache, direto do Claude ' +
268 'Code. Abaixo, o que cada aba mostra; w abre o navegador com a linha do tempo, os agentes, os tokens ' +
269 'e a memória. Troque de aba com 1 a 4; d move o radar para a lateral ou para baixo; h esconde esta ajuda.',
270 activities: 'Cada prompt que você envia é uma atividade. Abra uma com Enter para ver, em ordem, cada ' +
271 'ferramenta que ela chamou, inclusive as dos subagentes, com quando começou e quanto levou.',
272 insights: 'Regras fixas leem esta sessão e o histórico do projeto e sugerem mudanças na configuração. ' +
273 'Depois de aplicar uma, marque como aplicada: a aba compara as sessões antes e depois.',
274 config: 'Enter ou um clique muda uma linha. Tudo o que notifica ou guarda dados começa desligado.',
275 },
276 cmdConfig: 'Abre as configurações do radar: notificações, insights, idioma',
277 notifyDone: (n, dur, calls, fails, until) => `#${n} concluída · ${dur} · ${calls} ferramentas` +
278 (fails ? ` · ${plural(fails, 'falha', 'falhas')}` : '') + (until ? ` · cache até ${until}` : ''),
279 notifyCache: (left, tok, assumed) => `O cache da sessão principal expira em ${left} · ~${tok} tok seriam regravados` +
280 (assumed ? ' (TTL presumida 5m)' : ''),
281 cfgTitle: 'Configurações · Enter ou clique para mudar; valem para as próximas sessões',
282 cfgNotifyDone: 'Avisar quando uma atividade terminar',
283 cfgNotifyDoneMin: (s) => `só se levou pelo menos ${fmtDur(s * 1000)}`,
284 cfgNotifyCache: 'Avisar antes de o cache da sessão principal expirar',
285 cfgNotifyCacheLead: (s) => `com esta antecedência: ${fmtDur(s * 1000)}`,
286 cfgInsights: 'Insights, com histórico por projeto',
287 cfgAiHint: `Sugerir a análise com IA (${AI_REVIEW})`,
288 cfgClearHistory: 'Apagar o histórico guardado',
289 cfgAutoExport: 'Salvar o export automaticamente quando uma atividade terminar',
290 cfgAutoExportNote: 'Um arquivo por sessão nesta pasta, para a análise com IA. Não o versione.',
291 cfgBadge: (b) => `Sempre mostrar o crachá desta sessão (${b}) abaixo do prompt`,
292 cfgBadgeNote: 'Ele também aparece ali enquanto o radar no navegador roda, para achar a aba certa.',
293 cfgPosition: 'Posição do radar',
294 posSide: 'lateral (ao lado do transcript, em tela cheia)',
295 posBottom: 'abaixo (acima do prompt, compacto)',
296 cfgPositionNote: 'd no radar também alterna. Com menos de 110 colunas o Claude Code põe o painel lateral embaixo.',
297 dockBottom: 'Abaixo', dockSide: 'Na lateral', close: 'Fechar',
298 bandKeys: 'ctrl+x tab p/ usar as teclas',
299 cfgHistoryCleared: 'Histórico apagado.',
300 cfgNotifyNote: 'As notificações usam o canal de notificação do Claude Code, um aviso no canto onde ele não existe, e o navegador. Nunca um prompt.',
301 cfgInsightsNote: 'O histórico guarda contagens por projeto entre sessões: sem prompts, sem argumentos.',
302 insightsOff: 'Os insights estão desligados. Ligue em Config (4): eles leem esta sessão e guardam contagens por projeto.',
303 noFindings: 'Nada a sugerir para esta sessão por enquanto.',
304 historyLine: (n) => `Histórico: ${plural(n, 'sessão anterior', 'sessões anteriores')} deste projeto`,
305 markApplied: 'marcar aplicado', markedApplied: 'aplicado ✓',
306 moreInExport: (n) => `… mais ${n}: o export (e) traz todos`,
307 trends: 'Desde as mudanças marcadas como aplicadas',
308 trendLine: (name, before, after, nb, na) => `${name}: ${before} → ${after ?? '…'} (${nb} → ${na} sessões)`,
309 aiHint: `Análise com IA: e para exportar, depois ${AI_REVIEW} (usa tokens da sessão)`,
310 unit: { tokTurn: 'tok/turno', countTurn: '/turno', msTurn: '/turno', pct: '%', tok: 'tok' },
311 ins: {
312 'cache-expired': (d) => [
313 `O cache expirou ${d.count}× após uma pausa (maior ${fmtDur(d.longestGap)}): ~${fmtTok(d.rewritten)} tok reenviados, ~${fmtTok(d.waste)} tok-eq perdidos`,
314 d.oneHourNet == null ? 'Ligue o aviso de cache em Config para retomar antes de expirar'
315 : d.oneHourNet > 0 ? `Uma TTL de 1h teria economizado ~${fmtTok(d.oneHourNet)} tok-eq aqui; ou ligue o aviso de cache`
316 : 'Uma TTL de 1h não compensaria aqui (a gravação custa 2×); ligue o aviso de cache',
317 ],
318 'cache-model-switch': (d) => [
319 `Uma troca de modelo perdeu o cache ${d.count}× (${d.from} → ${d.to}): ~${fmtTok(d.rewritten)} tok reenviados`,
320 'Cada modelo tem o próprio cache: troque entre atividades, não no meio de uma',
321 ],
322 'denied-repeated': (d) => [
323 `${d.permission} negado ${d.count}×`,
324 d.safety === 'never' ? 'O Claude insiste: ponha em permissions.deny, ou diga no CLAUDE.md para não rodar'
325 : d.safety === 'safe' ? 'Só leitura: candidato a permissions.allow em .claude/settings.json'
326 : 'Se for seguro neste projeto, ponha em permissions.allow; se não, em permissions.deny',
327 ],
328 'failing-repeated': (d) => [
329 `${trunc(d.key, 50)} falhou ${d.count}×` + (d.sessions > 1 ? ` em ${d.sessions} sessões` : ''),
330 'Escreva o comando certo no CLAUDE.md, ou confira com um hook',
331 ],
332 'repeated-command': (d) => [
333 `"${trunc(d.label, 40)}" rodou ${d.count}× (${fmtDur(d.ms)})`,
334 'Um script, skill ou slash command faria isso num passo',
335 ],
336 'web-repeat': (d) => [`${trunc(d.url, 50)} buscado ${d.count}×`, 'Guarde uma cópia local dessa documentação, ou use um MCP de docs'],
337 'slow-tool': (d) => [`${d.name}: p95 de ${fmtDur(d.p95)} em ${d.count} chamadas`, 'Rode comandos longos em background, ou deixe-os mais rápidos'],
338 'subagent-model': (d) => [
339 `O subagente "${trunc(d.label, 30)}" só leu arquivos, em ${d.model} (${fmtTok(d.tokIn)} tok)`,
340 'Buscas só de leitura cabem no tipo Explore ou em model: haiku',
341 ],
342 'context-pressure': (d) => [`O contexto chegou a ${d.pct}%`, 'Use /compact entre atividades, ou enxugue o que sempre é carregado'],
343 'fixed-overhead': (d) => [
344 `A primeira requisição já envia ${fmtTok(d.tokens)} tok` + (d.median ? ` (mediana ${fmtTok(d.median)})` : ''),
345 'System prompt, ferramentas, MCP e CLAUDE.md são pagos em toda sessão: revise o que é carregado',
346 ],
347 'mcp-unused': (d) => [
348 `MCP sem uso em ${d.sessions} sessões: ${d.servers.join(', ')}`,
349 'As definições das ferramentas ainda ocupam contexto: desative-os no .mcp.json',
350 ],
351 'mcp-unreliable': (d) => [
352 `MCP ${d.server}: ${d.fails} de ${d.count} chamadas falharam, p95 de ${fmtDur(d.p95)}`,
353 'Verifique o servidor, ou desative-o se não estiver ajudando',
354 ],
355 },
356 ruleName: {
357 'cache-expired': 'Cache expirado', 'cache-model-switch': 'Troca de modelo', 'denied-repeated': 'Negações',
358 'failing-repeated': 'Falhas', 'repeated-command': 'Comando repetido', 'web-repeat': 'Busca repetida',
359 'slow-tool': 'Ferramenta lenta', 'subagent-model': 'Modelo do subagente', 'context-pressure': 'Contexto',
360 'fixed-overhead': 'Primeira requisição', 'mcp-unused': 'MCP sem uso', 'mcp-unreliable': 'MCP instável',
361 },
362 },
363}
364
365function detectLocale() {
366 try {
367 return /^pt\b/i.test(Intl.DateTimeFormat().resolvedOptions().locale) ? 'pt' : 'en'
368 } catch {
369 return 'en'
370 }
371}
372
373let langPref = 'auto'
374let L = STRINGS[detectLocale()]
375
376function setLanguage(pref) {
377 langPref = pref
378 L = STRINGS[pref === 'auto' ? detectLocale() : pref]
379}
380
381// Accepts what a person is likely to type: pt, pt-BR, português, en, english, auto
382function parseLanguage(text) {
383 const v = String(text ?? '').trim().toLowerCase()
384 if (/^pt|^port/.test(v)) return 'pt'
385 if (/^en|^ing/.test(v)) return 'en'
386 if (v === 'auto' || v === 'system' || v === 'sistema') return 'auto'
387 return null
388}
389
390async function saveLanguage($, pref) {
391 setLanguage(pref)
392 try {
393 await $.store.set('language', pref)
394 } catch {
395 // The store is unavailable here; the choice still holds for this session
396 }
397}
398
399let cfg = { ...DEFAULTS }
400
401// Only the known keys, each of its own type: a hand-edited store cannot turn on what it should not
402function readConfig(saved) {
403 const out = { ...DEFAULTS }
404 for (const [k, v] of Object.entries(DEFAULTS)) {
405 const got = saved?.[k]
406 if (typeof v === 'boolean' && typeof got === 'boolean') out[k] = got
407 if (typeof v !== 'boolean' && CHOICES[k]?.includes(got)) out[k] = got
408 }
409 return out
410}
411
412async function saveConfig($, patch) {
413 cfg = { ...cfg, ...patch }
414 try {
415 await $.store.set('config', cfg)
416 } catch {
417 // The store is unavailable here; the settings still hold for this session
418 }
419}
420
421const KIND = {
422 tool: { icon: '⚙', color: 'cyan' },
423 mcp: { icon: '⇄', color: 'magenta' },
424 web: { icon: '◎', color: 'blue' },
425 agent: { icon: '◆', color: 'yellow' },
426 api: { icon: '✦', color: 'green' },
427}
428
429// What is looked at in a glance or acted on stays here; the timeline, the agents and the tokens,
430// which need room to read, are in the browser view, whose first three views match these
431const TABS = [
432 ['home', 'tabHome', '1'],
433 ['activities', 'tabActivities', '2'],
434 ['insights', 'tabInsights', '3'],
435 ['config', 'tabConfig', '4'],
436]
437
438// ---------------------------------------------------------------------------
439// State: module variables shared by every hook
440// ---------------------------------------------------------------------------
441
442let S = fresh()
443let tab = 'home'
444let paneOpen = false
445let showHelp = false
446// When x was pressed once: a second press within CLEAR_CONFIRM_MS clears
447let clearArmedAt = 0
448// null follows the newest activity; 'none' keeps every activity closed
449let openTurn = null
450let seq = 0
451// The project the session runs in, which names its history; the history as stored; the MCP servers
452// the project's .mcp.json lists. The last two are read only while insights are on.
453let cwd = null
454let hist = null
455let mcpConfigured = []
456let histWrite = Promise.resolve()
457let badge = makeBadge(null)
458// The session's id, which names it on the shared browser view, and its title as Claude Code hands it
459// to the hooks (the /rename one, or the one it gave the conversation)
460let sessionKey = null
461let sessionTitle = null
462
463// What the radar recorded is also handed to Claude Code, which holds it for the session, so a reload
464// of the mod (an update, /reload-plugins, a hot reload while developing it) carries on from it
465// instead of starting over. It is written at most every SAVE_MS while something changed, and at
466// once when an activity ends, which is when a reload comes. Only the first session.start of a copy
467// of the module reads it back: that one is the reload, and a later one must not roll back to it.
468const SAVED = { plugin: 'session-radar', key: 'session' }
469let restoreDone = false
470let saveDirty = false
471let lastSave = 0
472let saving = Promise.resolve()
473
474function saveState($) {
475 if (!restoreDone) return saving
476 saveDirty = false
477 lastSave = Date.now()
478 const value = { v: 1, savedAt: lastSave, seq, exportRoot, title: sessionTitle, S: { ...S, agents: [...S.agents.values()] } }
479 saving = saving.then(() => $.state.set(SAVED, value)).catch(() => {
480 saveDirty = true
481 })
482 return saving
483}
484
485async function restoreState($) {
486 restoreDone = true
487 let saved
488 try {
489 saved = (await $.state.get(SAVED)).value
490 } catch {
491 // No state here: the radar starts empty, as before
492 return
493 }
494 if (saved?.v !== 1 || !Array.isArray(saved.S?.spans) || !Array.isArray(saved.S.agents)) return
495 if (typeof saved.exportRoot === 'string') exportRoot = saved.exportRoot
496 if (typeof saved.title === 'string') sessionTitle = saved.title
497 const base = fresh()
498 S = {
499 ...base,
500 ...saved.S,
501 totals: { ...base.totals, ...saved.S.totals },
502 agents: new Map([...base.agents, ...saved.S.agents.map((a) => [a.id, a])]),
503 }
504 // The calls still running when the old copy went away end with it: their outcome never reaches
505 // this one. Subagents stay open, since their end is reported to whichever copy is loaded.
506 if (!Array.isArray(S.hooks)) S.hooks = []
507 for (const s of [...S.spans, ...S.hooks]) {
508 if (s.end) continue
509 s.end = saved.savedAt ?? Date.now()
510 s.status = 'interrupted'
511 }
512 seq = Math.max(saved.seq ?? 0, ...S.spans.map((s) => s.id))
513}
514
515function fresh() {
516 const startedAt = Date.now()
517 return {
518 id: `${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
519 startedAt,
520 maxCtxPct: 0,
521 spans: [],
522 agents: new Map([['main', newAgent('main', { type: 'main', label: 'main', parent: null })]]),
523 turns: [],
524 turnSeq: 0,
525 hooks: [],
526 hookSeq: 0,
527 ttlSeen: false,
528 totals: {
529 calls: 0, mcp: 0, web: 0, agents: 0, api: 0, errors: 0, denied: 0,
530 tokIn: 0, tokOut: 0, cacheRead: 0, cacheWrite: 0, hooks: 0, hookBlocks: 0,
531 },
532 }
533}
534
535function newAgent(id, info) {
536 return {
537 id,
538 type: info.type ?? null,
539 label: info.label ?? id,
540 parent: info.parent === undefined ? 'main' : info.parent,
541 turn: info.turn ?? null,
542 start: Date.now(),
543 end: null,
544 status: 'running',
545 calls: 0,
546 api: 0,
547 tokIn: 0,
548 tokOut: 0,
549 recent: [],
550 cacheAt: null,
551 cachePrompt: 0,
552 ttl: null,
553 }
554}
555
556const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v != null))
557
558// The activity a main-loop event belongs to: the one still running, else the latest
559function currentTurn() {
560 return [...S.turns].reverse().find((t) => !t.end) ?? S.turns.at(-1) ?? null
561}
562
563const turnById = (id) => (id == null ? null : S.turns.find((t) => t.id === id) ?? null)
564
565// A subagent's calls belong to the activity that started it, even after that activity ended
566function turnOfAgent(agentId) {
567 if (agentId !== 'main') {
568 const a = S.agents.get(agentId)
569 if (a?.turn != null) return turnById(a.turn)
570 }
571 return currentTurn()
572}
573
574function ensureAgent(id, info = {}) {
575 id = id || 'main'
576 let a = S.agents.get(id)
577 if (!a) {
578 // Link a new subagent to the Agent/Task call that is still open and created it
579 const origin = [...S.spans].reverse().find((s) => s.kind === 'agent' && !s.end && !s.child)
580 if (origin) {
581 origin.child = id
582 info = { label: origin.label, parent: origin.agent, turn: origin.turn, ...clean(info) }
583 }
584 a = newAgent(id, { turn: currentTurn()?.id, ...info })
585 S.agents.set(id, a)
586 S.totals.agents++
587 } else {
588 Object.assign(a, clean(info))
589 }
590 return a
591}
592
593function openSpan(kind, name, label, agent) {
594 const turn = turnOfAgent(agent)?.id ?? null
595 const span = { id: ++seq, kind, name, label, agent, turn, start: Date.now(), end: null, status: 'running', meta: {} }
596 S.spans.push(span)
597 if (S.spans.length > MAX_SPANS) {
598 const i = S.spans.findIndex((s) => s.end)
599 if (i >= 0) S.spans.splice(i, 1)
600 }
601 return span
602}
603
604function closeSpan(span, status) {
605 span.end = Date.now()
606 span.status = status
607 if (status === 'error') S.totals.errors++
608 if (status === 'denied') S.totals.denied++
609}
610
611// ---------------------------------------------------------------------------
612// Hooks: every classic hook event Claude Code raises (PreToolUse, Stop, SessionStart, ...), whether
613// or not a settings hook answers it. Each keeps when it fired, how long the hooks beneath the radar
614// took, and what they answered, as one line of at most MAX_LABEL characters: never a prompt, a
615// message or a tool's output.
616// ---------------------------------------------------------------------------
617
618// These come before the prompt's activity starts, and join it once it does
619const PRE_TURN = new Set(['UserPromptSubmit', 'UserPromptExpansion'])
620const line = (v) => (typeof v === 'string' && v ? trunc(oneLine(v), MAX_LABEL) : null)
621
622// What the event is about: the tool for a tool's events, else the field that names its occasion
623function hookTarget(event, e) {
624 if (event === 'PreToolUse') return { tool: prettyName(e.tool ?? e.tool_name ?? '?'), label: summarize(e.tool_input ?? e) }
625 if (typeof e.tool_name === 'string') {
626 const input = e.tool_input && typeof e.tool_input === 'object' ? e.tool_input : {}
627 return { tool: prettyName(e.tool_name), label: summarize(input) }
628 }
629 const pick = {
630 SessionStart: e.source, SessionEnd: e.reason, Setup: e.trigger, PreCompact: e.trigger, PostCompact: e.trigger,
631 UserPromptSubmit: e.source, UserPromptExpansion: e.command_name,
632 Notification: [e.notification_type, e.message].filter(Boolean).join(': '),
633 SubagentStart: e.agent_type, SubagentStop: e.agent_type, TaskCreated: e.task_subject, TaskCompleted: e.task_subject,
634 TeammateIdle: e.teammate_name, Elicitation: e.mcp_server_name, ElicitationResult: [e.mcp_server_name, e.action].filter(Boolean).join(' · '),
635 ConfigChange: [e.source, e.file_path].filter(Boolean).join(' · '), CwdChanged: e.new_cwd, DirectoryAdded: e.directory,
636 FileChanged: [e.event, e.file_path].filter(Boolean).join(' '), InstructionsLoaded: [e.memory_type, e.file_path].filter(Boolean).join(' · '),
637 PreModelSwitch: e.from_model && `${e.from_model} → ${e.to_model}`, PostModelSwitch: e.from_model && `${e.from_model} → ${e.to_model}`,
638 WorktreeCreate: e.name, WorktreeRemove: e.worktree_path, StopFailure: typeof e.error === 'string' ? e.error : null,
639 PostToolBatch: Array.isArray(e.tool_calls) ? plural(e.tool_calls.length, 'call', 'calls') : null,
640 }
641 return { tool: null, label: line(pick[event]) ?? '' }
642}
643
644// What the hooks beneath answered, as far as the radar can tell from the folded answer
645function hookOutcome(r) {
646 if (!r || typeof r !== 'object') return {}
647 const ctx = Array.isArray(r.additionalContext) ? r.additionalContext.map(line).filter(Boolean) : []
648 return clean({
649 decision: r.allow ? 'allow' : typeof r.ask === 'string' ? 'ask' : typeof r.deny === 'string' ? 'deny' : null,
650 block: typeof r.block === 'string' ? line(r.block) ?? '' : null,
651 reason: line(r.deny ?? r.ask ?? r.stopReason),
652 stop: r.preventContinuation ? true : null,
653 ctx: ctx.length ? ctx.slice(0, 3) : null,
654 ctxCount: ctx.length > 3 ? ctx.length : null,
655 rewrote: r.updatedInput || r.updatedToolOutput || r.updatedMCPToolOutput || r.displayContent || r.worktreePath ? true : null,
656 retry: r.retry ? true : null,
657 title: line(r.sessionTitle),
658 })
659}
660
661function openHook(event, e) {
662 const agent = e.agentId ?? e.agent_id ?? 'main'
663 const pending = PRE_TURN.has(event) && agent === 'main'
664 const turn = pending ? null : agent !== 'main' ? turnOfAgent(agent) : S.turns.findLast((t) => !t.end) ?? null
665 const { tool, label } = hookTarget(event, e)
666 const hook = { id: ++S.hookSeq, event, tool, label, agent, turn: turn?.id ?? null, start: Date.now(), end: null, status: 'running' }
667 if (pending) hook.pending = true
668 S.hooks.push(hook)
669 if (S.hooks.length > MAX_HOOKS) {
670 const i = S.hooks.findIndex((h) => h.end)
671 if (i >= 0) S.hooks.splice(i, 1)
672 }
673 S.totals.hooks++
674 if (turn) turn.hooks = (turn.hooks ?? 0) + 1
675 return hook
676}
677
678function closeHook(hook, status, out) {
679 hook.end = Date.now()
680 hook.status = status
681 if (out && Object.keys(out).length) hook.out = out
682 if (status === 'blocked') S.totals.hookBlocks++
683}
684
685// The title Claude Code gives the session reaches the hooks; a hook may also set it
686function takeTitle(text) {
687 const t = line(text)
688 if (!t || t === sessionTitle) return
689 sessionTitle = t
690 saveDirty = true
691 markWebDirty()
692}
693
694// ---------------------------------------------------------------------------
695// Text helpers
696// ---------------------------------------------------------------------------
697
698const agentOf = (e) => e.agentId ?? 'main'
699const oneLine = (s) => String(s).replace(/\s+/g, ' ').trim()
700const trunc = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + '…' : s)
701const pad = (s, n) => trunc(s, n).padEnd(n)
702const redraw = ($) => {
703 markWebDirty()
704 saveDirty = true
705 $.ui.invalidate('ui.render')
706}
707const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
708
709function classify(tool) {
710 if (tool === 'Agent' || tool === 'Task') return 'agent'
711 if (/^mcp__/.test(tool)) return 'mcp'
712 if (tool === 'WebFetch' || tool === 'WebSearch') return 'web'
713 return 'tool'
714}
715
716function prettyName(tool) {
717 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
718 return m ? `${m[1]}·${m[2]}` : tool
719}
720
721// The tool's main argument, on one line and capped, so the radar never holds a whole prompt
722function summarize(e) {
723 const v = e.command ?? e.file_path ?? e.path ?? e.pattern ?? e.url ?? e.query ??
724 e.description ?? e.subagent_type ?? e.prompt
725 if (typeof v === 'string') return trunc(oneLine(v), MAX_LABEL)
726 const keys = Object.keys(e).filter((k) => !['tool', 'agentId'].includes(k))
727 return keys.slice(0, 3).join(', ')
728}
729
730const shortModel = (m) => (m ? String(m).replace(/^claude-/, '').replace(/-\d{8}$/, '') : null)
731
732function fmtDur(ms) {
733 if (ms < 1000) return `${ms}ms`
734 if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
735 const m = Math.floor(ms / 60000)
736 return `${m}m${String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')}s`
737}
738
739const fmtTok = (n) => (n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n))
740
741function bar(frac, w) {
742 const f = Math.max(0, Math.min(1, frac || 0))
743 const full = Math.round(f * w)
744 return '█'.repeat(full) + '░'.repeat(w - full)
745}
746
747function depthOf(id) {
748 let d = 0
749 let a = S.agents.get(id)
750 while (a && a.parent && d < 10) {
751 d++
752 a = S.agents.get(a.parent)
753 }
754 return d
755}
756
757function liveSuffix() {
758 const running = S.spans.filter((s) => !s.end)
759 const cur = running.filter((s) => s.kind !== 'api').at(-1) ?? running.at(-1)
760 const t = S.totals
761 const fails = t.errors + t.denied
762 const parts = []
763 if (cur) parts.push(`${KIND[cur.kind].icon} ${trunc(cur.name, 18)} ${fmtDur(Date.now() - cur.start)}`)
764 parts.push(`${t.calls} ${L.spinTools}`, `${t.api} ${L.spinReq}`)
765 if (t.agents) parts.push(plural(t.agents, L.spinAgent, L.spinAgents))
766 if (fails) parts.push(plural(fails, L.fail, L.fails))
767 return ' · ' + parts.join(' · ') + '…'
768}
769
770function snapshot() {
771 const out = {
772 tool: 'session-radar',
773 generator: GENERATOR,
774 exportedAt: new Date().toISOString(),
775 startedAt: new Date(S.startedAt).toISOString(),
776 totals: S.totals,
777 turns: S.turns,
778 agents: [...S.agents.values()],
779 spans: S.spans,
780 hooks: S.hooks,
781 }
782 // Added after the existing fields, and only when the person turned insights on
783 if (cfg.insights) {
784 out.insights = {
785 findings: findings(),
786 maxContextPercent: Math.round(S.maxCtxPct),
787 mcpConfigured,
788 history: pastSessions(),
789 applied: compareApplied(projectSessions(), appliedMarks()),
790 }
791 }
792 return out
793}
794
795// ---------------------------------------------------------------------------
796// Notifications. Numbers only: never a prompt or a tool's argument, since a notification can show
797// on a locked screen.
798// ---------------------------------------------------------------------------
799
800// The last ones, for the browser view to show as they come: kept in the module alone, never in the
801// record or the export
802const MAX_NOTICES = 20
803let notices = []
804let noticeSeq = 0
805
806function notify($, text, kind) {
807 notices.push({ id: ++noticeSeq, at: Date.now(), kind, text })
808 if (notices.length > MAX_NOTICES) notices.shift()
809 markWebDirty()
810 void pushWeb($)
811 void (async () => {
812 let sent = null
813 try {
814 sent = await $.ui.notify(text, { title: 'Session Radar' })
815 } catch {
816 // No notification channel here, or the write failed
817 }
818 // A terminal with no channel (Windows Terminal, under auto) shows nothing, and the bell shows no
819 // text: then a toast. Unless a Notification hook of the person's answered for it on purpose
820 if (sent?.isSent && sent.channel !== 'terminal_bell') return
821 if (sent?.reason === 'refused') return
822 try {
823 $.ui.toast(text, { timeoutMs: 8000 })
824 } catch {
825 // No toasts on this surface either
826 }
827 })()
828}
829
830// How long the loop's cache has left. An unknown TTL counts as 5 minutes, the shorter of the two.
831function cacheRemaining(a, now = Date.now()) {
832 if (!a?.cacheAt) return null
833 return TTL_MS[a.ttl === '1h' ? '1h' : '5m'] - (now - a.cacheAt)
834}
835
836const clockTime = (ms) => {
837 const d = new Date(ms)
838 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
839}
840
841// FNV-1a over the session id: the same session always gets the same badge, on every reload
842function makeBadge(id, project = null) {
843 let hash = 2166136261
844 for (const ch of String(id ?? Math.random())) {
845 hash ^= ch.codePointAt(0)
846 hash = Math.imul(hash, 16777619) >>> 0
847 }
848 let code = ''
849 let x = hash
850 for (let i = 0; i < 4; i++) {
851 code += BADGE_CHARS[x % BADGE_CHARS.length]
852 x = Math.floor(x / BADGE_CHARS.length)
853 }
854 return { code, mark: BADGE_MARKS[x % 4], color: BADGE_COLORS[Math.floor(x / 4) % 4], project }
855}
856
857const badgeText = () => `${badge.mark} ${badge.code}`
858const baseName = (p) => (p ? String(p).replace(/[\\/]+$/, '').split(/[\\/]/).pop() || p : null)
859
860// Under the prompt while the browser view runs, or always when the person asked for it
861function showBadge($) {
862 try {
863 $.ui.status(cfg.badge || web?.base ? `${badge.mark} radar ${badge.code}` : undefined)
864 } catch {
865 // No status line on this surface
866 }
867}
868
869function notifyTurnDone($, turn) {
870 if (!cfg.notifyDone || !turn?.end || turn.end - turn.start < cfg.notifyDoneMin * 1000) return
871 const main = S.agents.get('main')
872 const left = main?.ttl ? cacheRemaining(main) : null
873 const until = left > 0 ? clockTime(Date.now() + left) : null
874 notify($, L.notifyDone(turn.n, fmtDur(turn.end - turn.start), turn.calls, turn.fails, until), 'done')
875}
876
877// Once per cache entry, while nothing runs: a running activity keeps the cache alive by itself
878function checkCache($) {
879 if (!cfg.notifyCache) return
880 const a = S.agents.get('main')
881 if (!a?.cacheAt || a.cacheWarned === a.cacheAt || S.turns.some((t) => !t.end)) return
882 const left = cacheRemaining(a)
883 if (left <= 0 || left > cfg.notifyCacheLead * 1000) return
884 a.cacheWarned = a.cacheAt
885 notify($, L.notifyCache(fmtDur(Math.round(left / 1000) * 1000), fmtTok(a.cachePrompt), !a.ttl), 'cache')
886}
887
888// ---------------------------------------------------------------------------
889// Insights and the per-project history, both only while insights are on
890// ---------------------------------------------------------------------------
891
892const sessionView = () => ({ ...S, agents: [...S.agents.values()] })
893const projectSessions = () => (cwd ? hist?.projects?.[cwd]?.sessions ?? [] : [])
894const pastSessions = () => projectSessions().filter((r) => r.id !== S.id)
895const appliedMarks = () => (cwd ? hist?.applied?.[cwd] ?? [] : [])
896const findings = () => analyze(sessionView(), pastSessions(), mcpConfigured)
897
898async function loadInsights($) {
899 try {
900 const saved = await $.store.get('history')
901 hist = saved && typeof saved === 'object' ? saved : null
902 } catch {
903 hist = null
904 }
905 try {
906 const json = JSON.parse(await $.fs.read('.mcp.json'))
907 mcpConfigured = Object.keys(json?.mcpServers ?? {})
908 } catch {
909 // No .mcp.json in this project, or not JSON
910 mcpConfigured = []
911 }
912}
913
914function writeHistory($) {
915 const value = hist
916 histWrite = histWrite.then(() => $.store.set('history', value)).catch(() => {})
917 return histWrite
918}
919
920// Puts this session's counts into the history; a session with nothing in it is left out
921function saveSession($) {
922 if (!cfg.insights || !cwd || (!S.turns.length && !S.totals.calls)) return histWrite
923 hist = upsertHistory(hist, cwd, sessionRecord(sessionView(), S.id))
924 return writeHistory($)
925}
926
927// A second press takes the mark off
928function toggleApplied($, rule) {
929 if (!cwd) return
930 const marks = appliedMarks()
931 const has = marks.some((m) => m.rule === rule)
932 hist = {
933 projects: hist?.projects ?? {},
934 applied: { ...(hist?.applied ?? {}), [cwd]: has ? marks.filter((m) => m.rule !== rule) : [...marks, { rule, at: Date.now() }] },
935 }
936 writeHistory($)
937}
938
939async function clearHistory($) {
940 hist = null
941 try {
942 await histWrite
943 await $.store.delete('history')
944 } catch {
945 // The store is unavailable here
946 }
947 $.ui.toast(L.cfgHistoryCleared)
948}
949
950// The effort level, from the /config row that names it, when this build lists one; the session API
951// does not report it. Only that row's value is kept, read at most every EFFORT_MS while Home is drawn.
952let effortRead = { at: 0, value: null }
953
954async function readEffort($) {
955 if (Date.now() - effortRead.at < EFFORT_MS) return effortRead.value
956 let value = null
957 try {
958 const row = (await $.config.list())?.find?.((r) => /effort/i.test(`${r?.key ?? ''} ${r?.label ?? ''}`))
959 if (row && (typeof row.value === 'string' || typeof row.value === 'number')) value = trunc(String(row.value), 12)
960 } catch {
961 // No /config rows here: Home leaves the effort out
962 }
963 effortRead = { at: Date.now(), value }
964 return value
965}
966
967async function trackUsage($) {
968 try {
969 const u = await $.session.usage()
970 S.maxCtxPct = Math.max(S.maxCtxPct, contextPct(u?.context) ?? 0)
971 } catch {
972 // usage() is not available here
973 }
974}
975
976const exportName = (at) => `session-radar-${new Date(at).toISOString().replace(/[:.]/g, '-')}.json`
977
978// Exports go under the folder the session started in, kept across reloads, because a relative path
979// follows the current folder and a cd during the session would scatter the auto-saved file. The
980// folder ignores itself, so no project has to touch its own .gitignore.
981const EXPORT_DIR = '.claude/session-radar'
982let exportRoot = null
983let ignoreWritten = false
984
985async function exportPath($, name) {
986 const dir = exportRoot ? `${exportRoot}/${EXPORT_DIR}` : EXPORT_DIR
987 if (!ignoreWritten) {
988 await $.fs.write(`${dir}/.gitignore`, '*\n')
989 ignoreWritten = true
990 }
991 return `${dir}/${name}`
992}
993
994async function exportJson($) {
995 const name = exportName(Date.now())
996 const file = `${EXPORT_DIR}/${name}`
997 try {
998 await $.fs.write(await exportPath($, name), JSON.stringify(snapshot(), null, 2))
999 $.ui.toast(`${L.exported} ${file}`)
1000 return file
1001 } catch (err) {
1002 $.ui.toast(`${L.exportFailed} ${err?.message ?? err}`)
1003 return null
1004 }
1005}
1006
1007// With the setting on, one file per conversation, named after its start and rewritten after each
1008// activity, so it always holds the whole conversation so far. Quiet: a toast per activity would be
1009// noise, and a failed write only means the next activity tries again.
1010let autoWrite = Promise.resolve()
1011
1012function autoExport($) {
1013 if (!cfg.autoExport) return autoWrite
1014 const name = exportName(S.startedAt)
1015 const text = JSON.stringify(snapshot(), null, 2)
1016 autoWrite = autoWrite.then(async () => $.fs.write(await exportPath($, name), text)).catch(() => {})
1017 return autoWrite
1018}
1019
1020// ---------------------------------------------------------------------------
1021// Web view. The mod has no server of its own: it starts web/server.mjs as a child, which dies with
1022// the module, and sends it the same snapshot /radar-export writes, plus the session's usage. The
1023// page reads it from 127.0.0.1 with a token; nothing here depends on the server being up.
1024//
1025// Every session of this person shares one server, the hub. The hub file says where it listens and
1026// holds its two tokens, readable by this user alone. The first /radar-web starts it; the other
1027// sessions find it there and send their snapshots too, so one page shows them all. When the session
1028// whose child it is goes away, the next to notice starts another on the same port with the same
1029// tokens, and the open page carries on. /radar-web stop ends it for all of them.
1030// ---------------------------------------------------------------------------
1031
1032// null, or { stream, base, url, ingest, ready, timer, dirty, lastPush, pushing, fails }. `stream` is
1033// the server this session started, null in one that joined another's
1034let web = null
1035// 'on' once this session has used the view, so it starts the server again when it goes; 'auto' only
1036// joins one that runs
1037let webMode = 'auto'
1038let hubPath
1039let lastHubLook = 0
1040let hubLooking = false
1041const HUB_TOKEN = /^[\w-]{32}$/
1042
1043// A variable's value, or null when unset or unreadable. Each name is spelled at the call, so the
1044// variables the mod reads can be listed
1045async function envGet(read) {
1046 try {
1047 return (await read()) || null
1048 } catch {
1049 return null
1050 }
1051}
1052
1053// Under the person's Claude Code configuration, where their other sessions look too
1054async function hubFile($) {
1055 if (hubPath !== undefined) return hubPath
1056 const own = await envGet(() => $.env.get('SESSION_RADAR_HUB'))
1057 const config = await envGet(() => $.env.get('CLAUDE_CONFIG_DIR'))
1058 const home = (await envGet(() => $.env.get('USERPROFILE'))) ?? (await envGet(() => $.env.get('HOME')))
1059 hubPath = own ?? (config ? `${config}/session-radar/hub.json` : home ? `${home}/.claude/session-radar/hub.json` : null)
1060 return hubPath
1061}
1062
1063async function readHub($) {
1064 const file = await hubFile($)
1065 if (!file) return null
1066 try {
1067 const hub = JSON.parse(await $.fs.read(file))
1068 const portOk = Number.isInteger(hub?.port) && hub.port > 0 && hub.port < 65536
1069 return portOk && HUB_TOKEN.test(hub.viewToken) && HUB_TOKEN.test(hub.ingestToken) ? hub : null
1070 } catch {
1071 // No hub yet, or a file that is not one
1072 return null
1073 }
1074}
1075
1076const hubBase = (hub) => `http://127.0.0.1:${hub.port}`
1077
1078// Whether a server that knows this ingest token answers there: the file may name one long gone
1079async function hubAnswers($, hub) {
1080 try {
1081 const res = await $.http.fetch(`${hubBase(hub)}/api/hello`, { method: 'POST', headers: { 'x-radar-token': hub.ingestToken } })
1082 return res.status === 204
1083 } catch {
1084 return false
1085 }
1086}
1087
1088function markWebDirty() {
1089 if (web) web.dirty = true
1090}
1091
1092function usageSnapshot(u) {
1093 if (!u) return null
1094 const cost = usageCost(u)
1095 return clean({
1096 context: u.context ? clean({ tokens: u.context.tokens, window: u.context.window, percent: u.context.percent }) : null,
1097 costUsd: typeof cost === 'number' ? cost : null,
1098 rateLimits: (u.rateLimits ?? []).map((r) => clean({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
1099 })
1100}
1101
1102// The Insights tab as the page shows it, read-only, with the texts already in the pane's language,
1103// so the rules' wording lives in one place. Marking a change as applied stays in the pane.
1104function insightsView() {
1105 if (!cfg.insights) return null
1106 const marked = new Set(appliedMarks().map((m) => m.rule))
1107 return {
1108 history: pastSessions().length,
1109 findings: findings().map((f) => {
1110 const [title, hint] = L.ins[f.rule](f.data)
1111 return { rule: f.rule, severity: f.severity, title, hint, applied: marked.has(f.rule) }
1112 }),
1113 trends: compareApplied(projectSessions(), appliedMarks()).map((t) => ({
1114 rule: t.rule,
1115 text: L.trendLine(L.ruleName[t.rule], fmtMetric(t.before, t.unit) ?? '—', fmtMetric(t.after, t.unit), t.nBefore, t.nAfter),
1116 better: t.after != null && t.before != null && t.after < t.before,
1117 })),
1118 aiHint: cfg.aiHint ? L.aiHint : null,
1119 }
1120}
1121
1122async function pushWeb($) {
1123 const w = web
1124 if (!w?.base || w.pushing) return
1125 if (!w.dirty && Date.now() - w.lastPush < WEB_BEAT_MS) return
1126 w.pushing = true
1127 w.dirty = false
1128 try {
1129 let usage = null
1130 try {
1131 usage = usageSnapshot(await $.session.usage())
1132 } catch {
1133 // usage() is not available here; the page shows what the radar collected
1134 }
1135 let model = null
1136 try {
1137 model = shortModel(await $.session.model())
1138 } catch {
1139 // The model is not available here
1140 }
1141 const effort = await readEffort($)
1142 const body = JSON.stringify({
1143 ...snapshot(),
1144 now: Date.now(),
1145 lang: L === STRINGS.pt ? 'pt' : 'en',
1146 ttlSeen: S.ttlSeen,
1147 usage,
1148 session: clean({ ...badge, key: sessionKey ?? S.id, title: sessionTitle, cwd, model, effort }),
1149 insightsView: insightsView(),
1150 // The page shows the ones newer than it has seen, as a toast and, when allowed, a notification
1151 notices,
1152 version: VERSION,
1153 })
1154 const res = await $.http.fetch(`${w.base}/api/ingest`, {
1155 method: 'POST',
1156 headers: { 'content-type': 'application/json', 'x-radar-token': w.ingest },
1157 body,
1158 })
1159 w.lastPush = Date.now()
1160 if (!res.ok) throw new Error(`status ${res.status}`)
1161 w.fails = 0
1162 } catch {
1163 w.dirty = true
1164 // The server went away, or another took its port: look for the hub again
1165 if (++w.fails >= HUB_FAILS && web === w) dropWeb($)
1166 } finally {
1167 w.pushing = false
1168 }
1169}
1170
1171// Takes the server at `hub`: from now on this session's snapshots go there
1172function attach($, w, hub) {
1173 w.base = hubBase(hub)
1174 w.url = `${w.base}/?t=${hub.viewToken}`
1175 w.ingest = hub.ingestToken
1176 w.timer = $.clock.every(WEB_PUSH_MS, () => pushWeb($))
1177 webMode = 'on'
1178 void pushWeb($)
1179 showBadge($)
1180 $.ui.invalidate('ui.render')
1181 return w.url
1182}
1183
1184// Forgets the server, and ends it when it is this session's child
1185function dropWeb($) {
1186 const w = web
1187 if (!w) return
1188 web = null
1189 w.timer?.cancel()
1190 // Not awaited: it settles only once the child has gone
1191 try {
1192 w.stream?.return?.()?.catch?.(() => {})
1193 } catch {
1194 // The stream had already ended
1195 }
1196 if (!w.base) return
1197 showBadge($)
1198 $.ui.invalidate('ui.render')
1199}
1200hooks/insights.js 479 lines1// Copyright 2026 Neural Seam
2// SPDX-License-Identifier: Apache-2.0
3
4// Insights: deterministic rules over what the radar recorded, and over the per-project history of
5// earlier sessions when the person turned it on. Pure functions: no `$`, no text. Each finding
6// carries a rule id and plain data; register.js turns it into words, in the pane's language.
7
8export const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
9
10// What a token costs, as a multiple of one uncached input token
11const PRICE = { read: 0.1, write5m: 1.25, write1h: 2 }
12
13// Thresholds, each the smallest value that makes a finding
14const MISS_MIN_PROMPT = 2000
15const MISS_MAX_HIT = 0.5
16const DENIED_MIN = 2
17const FAILING_MIN = 3
18const REPEATED_CMD_MIN = 5
19const WEB_REPEAT_MIN = 3
20const SLOW_MIN_CALLS = 3
21const SLOW_P95_MS = 60_000
22const SUBAGENT_MIN_TOKENS = 20_000
23const CONTEXT_PCT = 80
24const OVERHEAD_TOKENS = 30_000
25const OVERHEAD_OVER_MEDIAN = 1.5
26const MCP_UNUSED_MIN_SESSIONS = 5
27const MCP_MIN_CALLS = 3
28const MCP_FAIL_RATE = 0.3
29const MCP_P95_MS = 30_000
30
31export const HISTORY_PER_PROJECT = 30
32export const HISTORY_PROJECTS = 20
33
34const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS'])
35
36// Bash prefixes that are safe to allow, and those never to allow: Claude asking for them again and
37// again is better answered with a deny rule
38const SAFE_PREFIX = /^(ls|cat|head|tail|pwd|echo|wc|grep|rg|find|which|type|tree|stat|du|df|git (status|diff|log|show|branch|remote|rev-parse|blame)|npm (ls|view|outdated)|node --version|python --version)\b/
39const NEVER_PREFIX = /^(rm|sudo|su|dd|mkfs|chmod|chown|shutdown|reboot|curl|wget|git (push|reset|clean|checkout|rebase)|npm publish|docker (rm|rmi|system)|kubectl delete)\b|--force\b|\s-f\b/
40
41const SEVERITY = { high: 0, medium: 1, low: 2 }
42
43const isMiss = (m) => {
44 const prompt = (m.tin ?? 0) + (m.cr ?? 0) + (m.cw ?? 0)
45 return prompt > MISS_MIN_PROMPT && (m.cr ?? 0) / prompt < MISS_MAX_HIT
46}
47
48function p95(values) {
49 if (!values.length) return 0
50 const sorted = [...values].sort((a, b) => a - b)
51 return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)]
52}
53
54function median(values) {
55 if (!values.length) return 0
56 const s = [...values].sort((a, b) => a - b)
57 const mid = Math.floor(s.length / 2)
58 return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2
59}
60
61// The command's first words, enough to name a permission rule: `npm run test` -> `npm run`
62export function commandPrefix(label) {
63 const words = String(label ?? '').trim().split(/\s+/).filter(Boolean)
64 if (!words.length) return ''
65 const second = words[1]
66 if (!second || /^-|[/\\.=]/.test(second)) return words[0]
67 return `${words[0]} ${second}`
68}
69
70export const mcpServer = (name) => (name.includes('·') ? name.split('·')[0] : null)
71
72const doneCalls = (spans) => spans.filter((s) => s.kind !== 'api' && s.end)
73const apiByAgent = (spans) => {
74 const by = new Map()
75 for (const s of spans) {
76 if (s.kind !== 'api' || !s.end) continue
77 if (!by.has(s.agent)) by.set(s.agent, [])
78 by.get(s.agent).push(s)
79 }
80 for (const list of by.values()) list.sort((a, b) => a.start - b.start)
81 return by
82}
83
84// Requests that sent their prompt uncached again: after the cache expired, or after a model switch
85export function cacheLosses(session) {
86 const ttlOf = new Map((session.agents ?? []).map((a) => [a.id, a.ttl]))
87 const expired = []
88 const switched = []
89 for (const [agent, list] of apiByAgent(session.spans ?? [])) {
90 for (let i = 1; i < list.length; i++) {
91 const prev = list[i - 1]
92 const s = list[i]
93 if (s.meta?.first || !isMiss(s.meta ?? {})) continue
94 const rewritten = (s.meta.tin ?? 0) + (s.meta.cw ?? 0)
95 const gap = s.start - (prev.end ?? prev.start)
96 const ttl = ttlOf.get(agent) === '1h' ? '1h' : '5m'
97 if (prev.name !== s.name) switched.push({ id: s.id, agent, rewritten, from: prev.name, to: s.name })
98 else if (gap > TTL_MS[ttl]) expired.push({ id: s.id, agent, rewritten, gap, ttl })
99 }
100 }
101 return { expired, switched }
102}
103
104function cacheFindings(session) {
105 const out = []
106 const { expired, switched } = cacheLosses(session)
107 if (expired.length) {
108 const rewritten = expired.reduce((n, x) => n + x.rewritten, 0)
109 const fiveMin = expired.filter((x) => x.ttl === '5m')
110 const ttl = fiveMin.length ? '5m' : '1h'
111 const waste = Math.round(rewritten * ((ttl === '5m' ? PRICE.write5m : PRICE.write1h) - PRICE.read))
112 // With a 1 hour TTL the losses after a pause under an hour would have been reads, but every
113 // write would cost 2x instead of 1.25x. Positive: the 1 hour TTL would have saved tokens.
114 let oneHourNet = null
115 if (fiveMin.length) {
116 const saved = fiveMin.filter((x) => x.gap < TTL_MS['1h']).reduce((n, x) => n + x.rewritten, 0) *
117 (PRICE.write5m - PRICE.read)
118 const writes = (session.spans ?? []).filter((s) => s.kind === 'api').reduce((n, s) => n + (s.meta?.cw ?? 0), 0)
119 oneHourNet = Math.round(saved - (writes - fiveMin.reduce((n, x) => n + x.rewritten, 0)) * (PRICE.write1h - PRICE.write5m))
120 }
121 out.push({
122 rule: 'cache-expired',
123 key: 'cache-expired',
124 severity: waste > 100_000 ? 'high' : waste > 20_000 ? 'medium' : 'low',
125 data: { count: expired.length, rewritten, waste, ttl, longestGap: Math.max(...expired.map((x) => x.gap)), oneHourNet },
126 evidence: expired.map((x) => x.id),
127 })
128 }
129 if (switched.length) {
130 const rewritten = switched.reduce((n, x) => n + x.rewritten, 0)
131 out.push({
132 rule: 'cache-model-switch',
133 key: 'cache-model-switch',
134 severity: rewritten > 50_000 ? 'medium' : 'low',
135 data: { count: switched.length, rewritten, from: switched[0].from, to: switched[0].to },
136 evidence: switched.map((x) => x.id),
137 })
138 }
139 return out
140}
141
142function groupBy(items, keyOf) {
143 const m = new Map()
144 for (const it of items) {
145 const k = keyOf(it)
146 if (k == null) continue
147 if (!m.has(k)) m.set(k, [])
148 m.get(k).push(it)
149 }
150 return m
151}
152
153function deniedFindings(session) {
154 const denied = doneCalls(session.spans ?? []).filter((s) => s.status === 'denied')
155 const out = []
156 for (const [key, list] of groupBy(denied, (s) => (s.name === 'Bash' ? `Bash ${commandPrefix(s.label)}` : s.name))) {
157 if (list.length < DENIED_MIN) continue
158 const tool = list[0].name
159 const prefix = tool === 'Bash' ? commandPrefix(list[0].label) : ''
160 const sample = list[0].label ?? ''
161 const safety = tool !== 'Bash' ? 'review' : NEVER_PREFIX.test(sample) || NEVER_PREFIX.test(prefix) ? 'never'
162 : SAFE_PREFIX.test(prefix) ? 'safe' : 'review'
163 out.push({
164 rule: 'denied-repeated',
165 key: 'denied:' + key,
166 severity: list.length >= 4 ? 'medium' : 'low',
167 data: { tool, prefix, count: list.length, safety, permission: tool === 'Bash' ? `Bash(${prefix}:*)` : tool },
168 evidence: list.map((s) => s.id),
169 })
170 }
171 return out
172}
173
174// What a failure is keyed by: the command's prefix for Bash, the server tool for MCP, else the tool
175// and its argument
176export function failKey(span) {
177 if (span.name === 'Bash') return `Bash ${commandPrefix(span.label)}`
178 if (span.kind === 'mcp') return span.name
179 return `${span.name} ${String(span.label ?? '').slice(0, 60)}`.trim()
180}
181
182function failingFindings(session, history) {
183 const fails = doneCalls(session.spans ?? []).filter((s) => s.status === 'error' && s.kind !== 'agent')
184 const now = groupBy(fails, failKey)
185 const past = new Map()
186 for (const rec of history) {
187 for (const [k, n] of Object.entries(rec.failKeys ?? {})) {
188 const cur = past.get(k) ?? { n: 0, sessions: 0 }
189 cur.n += n
190 cur.sessions++
191 past.set(k, cur)
192 }
193 }
194 const keys = new Set([...now.keys(), ...past.keys()])
195 const out = []
196 for (const k of keys) {
197 const here = now.get(k)?.length ?? 0
198 const before = past.get(k) ?? { n: 0, sessions: 0 }
199 const count = here + before.n
200 if (!here || count < FAILING_MIN) continue
201 out.push({
202 rule: 'failing-repeated',
203 key: 'failing:' + k,
204 severity: count >= 6 ? 'medium' : 'low',
205 data: { key: k, count, here, sessions: before.sessions + 1 },
206 evidence: (now.get(k) ?? []).map((s) => s.id),
207 })
208 }
209 return out
210}
211
212function repeatFindings(session) {
213 const calls = doneCalls(session.spans ?? [])
214 const out = []
215 for (const [label, list] of groupBy(calls.filter((s) => s.name === 'Bash'), (s) => s.label || null)) {
216 if (list.length < REPEATED_CMD_MIN) continue
217 const ms = list.reduce((n, s) => n + (s.end - s.start), 0)
218 out.push({
219 rule: 'repeated-command',
220 key: 'repeated:' + label,
221 severity: ms > 5 * 60_000 ? 'medium' : 'low',
222 data: { label, count: list.length, ms },
223 evidence: list.map((s) => s.id),
224 })
225 }
226 for (const [url, list] of groupBy(calls.filter((s) => s.name === 'WebFetch'), (s) => s.label || null)) {
227 if (list.length < WEB_REPEAT_MIN) continue
228 out.push({
229 rule: 'web-repeat',
230 key: 'web:' + url,
231 severity: 'low',
232 data: { url, count: list.length },
233 evidence: list.map((s) => s.id),
234 })
235 }
236 return out
237}
238
239function slowFindings(session) {
240 const calls = doneCalls(session.spans ?? []).filter((s) => s.kind === 'tool' || s.kind === 'web')
241 const out = []
242 for (const [name, list] of groupBy(calls, (s) => s.name)) {
243 if (list.length < SLOW_MIN_CALLS) continue
244 const durs = list.map((s) => s.end - s.start)
245 const slow = p95(durs)
246 if (slow <= SLOW_P95_MS) continue
247 out.push({
248 rule: 'slow-tool',
249 key: 'slow:' + name,
250 severity: 'low',
251 data: { name, count: list.length, p95: slow, ms: durs.reduce((a, b) => a + b, 0) },
252 evidence: list.map((s) => s.id),
253 })
254 }
255 return out
256}
257
258function subagentFindings(session) {
259 const out = []
260 const api = apiByAgent(session.spans ?? [])
261 for (const a of session.agents ?? []) {
262 if (a.id === 'main' || (a.tokIn ?? 0) <= SUBAGENT_MIN_TOKENS) continue
263 const calls = (session.spans ?? []).filter((s) => s.agent === a.id && s.kind !== 'api')
264 if (!calls.length || !calls.every((s) => READ_ONLY_TOOLS.has(s.name))) continue
265 const models = [...new Set((api.get(a.id) ?? []).map((s) => s.name))]
266 const model = models.find((m) => !/haiku/i.test(m))
267 if (!model) continue
268 out.push({
269 rule: 'subagent-model',
270 key: 'subagent:' + (a.type ?? a.label),
271 severity: a.tokIn > 200_000 ? 'medium' : 'low',
272 data: { label: a.label, type: a.type ?? null, model, tokIn: a.tokIn, calls: calls.length },
273 evidence: calls.map((s) => s.id),
274 })
275 }
276 return out
277}
278
279// The first request of the main loop: system prompt, tools, CLAUDE.md and the first prompt
280export function firstPromptTokens(spans) {
281 const first = (spans ?? []).filter((s) => s.kind === 'api' && s.agent === 'main' && s.end)
282 .sort((a, b) => a.start - b.start)[0]
283 return first ? (first.meta?.tin ?? 0) + (first.meta?.cr ?? 0) + (first.meta?.cw ?? 0) : 0
284}
285
286function contextFindings(session, history) {
287 const out = []
288 if ((session.maxCtxPct ?? 0) > CONTEXT_PCT) {
289 out.push({
290 rule: 'context-pressure',
291 key: 'context-pressure',
292 severity: session.maxCtxPct > 90 ? 'medium' : 'low',
293 data: { pct: Math.round(session.maxCtxPct) },
294 evidence: [],
295 })
296 }
297 const tokens = firstPromptTokens(session.spans)
298 const past = history.map((r) => r.firstPrompt).filter((n) => n > 0)
299 const med = past.length >= 3 ? median(past) : 0
300 if (tokens > OVERHEAD_TOKENS || (med && tokens > med * OVERHEAD_OVER_MEDIAN)) {
301 out.push({
302 rule: 'fixed-overhead',
303 key: 'fixed-overhead',
304 severity: tokens > 2 * OVERHEAD_TOKENS ? 'medium' : 'low',
305 data: { tokens, median: med || null },
306 evidence: [],
307 })
308 }
309 return out
310}
311
312function mcpFindings(session, history, mcpConfigured) {
313 const out = []
314 const calls = doneCalls(session.spans ?? []).filter((s) => s.kind === 'mcp')
315 for (const [server, list] of groupBy(calls, (s) => mcpServer(s.name))) {
316 if (list.length < MCP_MIN_CALLS) continue
317 const fails = list.filter((s) => s.status === 'error').length
318 const slow = p95(list.map((s) => s.end - s.start))
319 if (fails / list.length < MCP_FAIL_RATE && slow <= MCP_P95_MS) continue
320 out.push({
321 rule: 'mcp-unreliable',
322 key: 'mcp-unreliable:' + server,
323 severity: fails / list.length >= 0.5 ? 'medium' : 'low',
324 data: { server, count: list.length, fails, p95: slow },
325 evidence: list.map((s) => s.id),
326 })
327 }
328 const sessions = history.length + 1
329 if (mcpConfigured?.length && sessions >= MCP_UNUSED_MIN_SESSIONS) {
330 const used = new Set(calls.map((s) => mcpServer(s.name)))
331 for (const rec of history) for (const srv of rec.mcpUsed ?? []) used.add(srv)
332 const unused = mcpConfigured.filter((srv) => !used.has(srv))
333 if (unused.length) {
334 out.push({
335 rule: 'mcp-unused',
336 key: 'mcp-unused',
337 severity: 'low',
338 data: { servers: unused, sessions },
339 evidence: [],
340 })
341 }
342 }
343 return out
344}
345
346/**
347 * Every finding for this session, most severe first.
348 * @param session {spans, turns, agents, totals, maxCtxPct}, as the radar holds them
349 * @param history this project's earlier sessions, as `summarize` made them (the current one left out)
350 * @param mcpConfigured the server names the project's .mcp.json lists
351 */
352export function analyze(session, history = [], mcpConfigured = []) {
353 const findings = [
354 ...cacheFindings(session),
355 ...deniedFindings(session),
356 ...failingFindings(session, history),
357 ...repeatFindings(session),
358 ...slowFindings(session),
359 ...subagentFindings(session),
360 ...contextFindings(session, history),
361 ...mcpFindings(session, history, mcpConfigured),
362 ]
363 return findings.sort((a, b) => SEVERITY[a.severity] - SEVERITY[b.severity])
364}
365
366/**
367 * One session as the history keeps it: counts and sums, with no prompt and no argument beyond the
368 * first words of a failing command.
369 */
370export function summarize(session, id) {
371 const calls = doneCalls(session.spans ?? [])
372 const byTool = {}
373 for (const s of calls) {
374 const t = (byTool[s.name] ??= { n: 0, ms: 0, fail: 0 })
375 t.n++
376 t.ms += s.end - s.start
377 if (s.status !== 'ok') t.fail++
378 }
379 const failKeys = {}
380 for (const s of calls) {
381 if (s.status !== 'error' || s.kind === 'agent') continue
382 const k = failKey(s)
383 failKeys[k] = (failKeys[k] ?? 0) + 1
384 }
385 const { expired, switched } = cacheLosses(session)
386 const t = session.totals ?? {}
387 return {
388 id,
389 at: session.startedAt,
390 durMs: Date.now() - session.startedAt,
391 turns: (session.turns ?? []).length,
392 calls: t.calls ?? 0,
393 fails: t.errors ?? 0,
394 denied: t.denied ?? 0,
395 api: t.api ?? 0,
396 tokIn: t.tokIn ?? 0,
397 tokOut: t.tokOut ?? 0,
398 cacheRead: t.cacheRead ?? 0,
399 cacheWrite: t.cacheWrite ?? 0,
400 rewritten: [...expired, ...switched].reduce((n, x) => n + x.rewritten, 0),
401 firstPrompt: firstPromptTokens(session.spans),
402 maxCtxPct: Math.round(session.maxCtxPct ?? 0),
403 byTool,
404 failKeys,
405 mcpUsed: [...new Set(calls.filter((s) => s.kind === 'mcp').map((s) => mcpServer(s.name)))],
406 }
407}
408
409// failKeys hold the first words of a command; keep them as short as the labels the radar shows
410const capKeys = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k.slice(0, 80), v]))
411
412/**
413 * The stored history with this session put in (or replaced), within the limits.
414 * @param store `{ projects: { [cwd]: { at, sessions } }, applied: { [cwd]: [{ rule, at }] } }`
415 */
416export function upsertHistory(store, cwd, record) {
417 const next = { projects: { ...(store?.projects ?? {}) }, applied: { ...(store?.applied ?? {}) } }
418 const project = next.projects[cwd] ?? { at: 0, sessions: [] }
419 const sessions = project.sessions.filter((r) => r.id !== record.id)
420 sessions.push({ ...record, failKeys: capKeys(record.failKeys ?? {}) })
421 sessions.sort((a, b) => a.at - b.at)
422 next.projects[cwd] = { at: Date.now(), sessions: sessions.slice(-HISTORY_PER_PROJECT) }
423 const names = Object.keys(next.projects)
424 if (names.length > HISTORY_PROJECTS) {
425 const oldest = names.sort((a, b) => next.projects[a].at - next.projects[b].at).slice(0, names.length - HISTORY_PROJECTS)
426 for (const n of oldest) {
427 delete next.projects[n]
428 delete next.applied[n]
429 }
430 }
431 return next
432}
433
434// The number each rule is meant to bring down, per session
435const perTurn = (n, r) => n / Math.max(1, r.turns)
436export const METRIC = {
437 'cache-expired': (r) => perTurn(r.rewritten, r),
438 'cache-model-switch': (r) => perTurn(r.rewritten, r),
439 'denied-repeated': (r) => perTurn(r.denied, r),
440 'failing-repeated': (r) => perTurn(r.fails, r),
441 'repeated-command': (r) => perTurn(Object.values(r.byTool ?? {}).reduce((n, t) => n + t.ms, 0), r),
442 'slow-tool': (r) => perTurn(Object.values(r.byTool ?? {}).reduce((n, t) => n + t.ms, 0), r),
443 'web-repeat': (r) => perTurn(r.tokIn, r),
444 'subagent-model': (r) => perTurn(r.tokIn, r),
445 'mcp-unreliable': (r) => perTurn(r.tokIn, r),
446 'context-pressure': (r) => r.maxCtxPct,
447 'fixed-overhead': (r) => r.firstPrompt,
448 'mcp-unused': (r) => r.firstPrompt,
449}
450
451export const METRIC_UNIT = {
452 'cache-expired': 'tokTurn', 'cache-model-switch': 'tokTurn', 'denied-repeated': 'countTurn',
453 'failing-repeated': 'countTurn', 'repeated-command': 'msTurn', 'slow-tool': 'msTurn', 'web-repeat': 'tokTurn',
454 'subagent-model': 'tokTurn', 'mcp-unreliable': 'tokTurn', 'context-pressure': 'pct', 'fixed-overhead': 'tok',
455 'mcp-unused': 'tok',
456}
457
458/**
459 * For each change marked as applied, the rule's metric averaged over the sessions before and after
460 * the mark. `after` is null until a session has ended after it.
461 */
462export function compareApplied(sessions, applied) {
463 const avg = (list, f) => (list.length ? list.reduce((n, r) => n + f(r), 0) / list.length : null)
464 return (applied ?? []).filter((a) => METRIC[a.rule]).map((a) => {
465 const f = METRIC[a.rule]
466 const before = sessions.filter((r) => r.at < a.at)
467 const after = sessions.filter((r) => r.at >= a.at)
468 return {
469 rule: a.rule,
470 at: a.at,
471 unit: METRIC_UNIT[a.rule],
472 before: avg(before, f),
473 after: avg(after, f),
474 nBefore: before.length,
475 nAfter: after.length,
476 }
477 })
478}
479types/index.d.ts 34 lines1// Copyright 2026 Neural Seam
2// SPDX-License-Identifier: Apache-2.0
3
4// What the radar keeps in the session through $.state, so a reload of the mod carries on from it:
5// everything it recorded (the same fields as the export), held by Claude Code until the session ends.
6export type SessionRadarSaved = {
7 v: 1
8 savedAt: number
9 seq: number
10 exportRoot: string | null
11 // The title Claude Code gave the session, as its hooks receive it
12 title?: string | null
13 S: {
14 id: string
15 startedAt: number
16 maxCtxPct: number
17 spans: Record<string, unknown>[]
18 agents: Record<string, unknown>[]
19 turns: Record<string, unknown>[]
20 turnSeq: number
21 // Every classic hook event seen, and their count; absent in what 0.7 saved
22 hooks?: Record<string, unknown>[]
23 hookSeq?: number
24 ttlSeen: boolean
25 totals: Record<string, number>
26 }
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-radar': { session: SessionRadarSaved }
32 }
33}
34