SLOPSHOPPER

session-radar

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…

panebandspinnerguardcommandtoast
★ 4v0.8.0Apache-2.0updated 2026-10-21NeuralSeam/ns-session-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-radar
│ ┃ Session Radar ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 1: Home 2: Activities 3: Insights 4: Config │ session-radar │ │ ┃ e: Export x: Clear w: Browser d: Dock bel ⏺ Read(src/auth.ts) │ Radar exported to │ │ ┃ ▲ 8THM ⏱ 65ms · 1 activity · 9 tools · 1 fai ⎿ Read 6 line│ .claude/session-radar/session-radar-2026-10-26T17-1 │ ┃ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ Settings · Enter or click to change; kept f… ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ ] Notify when an activity ends ⎿ 3 pass, 1 fail │ ┃ only if it took at least 30.0s │ ┃ [ ] Warn before the main session cache expir ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ this long before: 1m00s │ ┃ Notifications use your Claude Code noti… ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ ] Insights, with per-project history › /radar │ ┃ [ ] Suggest the AI review (/session-radar:ra │ ┃ History keeps counts per project betwee… │ ┃ Delete the stored history │ ┃ │ ┃ [ ] Save the export automatically when an ac │ ┃ One file per session in this folder, fo… │ ┃ │ ┃ [ ] Always show this session's badge (▲ 8THM │ ┃ It also shows there while the browser v… │ ┃ │ ┃ Radar position: side (beside the transcript, │ ┃ d in the radar switches too. Under 110 … ✻ Thinking · 9 tools · 0 req. · 1 failure…… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session Radar
1: Home 2: Activities 3: Insights 4: Config e: Export x: Clear w: Browser d: Dock below h: Help ▲ 8THM ⏱ 65ms · 1 activity · 9 tools · 1 failure Settings · Enter or click to change; kept for your ne… [ ] Notify when an activity ends only if it took at least 30.0s [ ] Warn before the main session cache expires this long before: 1m00s Notifications use your Claude Code notification c… [ ] Insights, with per-project history [ ] Suggest the AI review (/session-radar:radar-revie… History keeps counts per project between sessions… Delete the stored history [ ] Save the export automatically when an activity en… One file per session in this folder, for the AI r… [ ] Always show this session's badge (▲ 8THM) under t… It also shows there while the browser view runs, … Radar position: side (beside the transcript, in fulls… d in the radar switches too. Under 110 columns Cl… Language: auto (system language) Session Radar · by Neural Seam · neuralseam.cloud · v…
README

Session Radar

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 from 127.0.0.1 alone, behind a random token. SECURITY.md lists exactly what it calls, and claude plugin validate . prints the same list from the code.

  • Portuguese: README.pt-BR.md (short guide; this file is canonical)
  • From the makers of: Neural Seam. Session Radar does not need Neural Seam, an account, or anything else installed.

What it shows

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.

In the terminal

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:

RuleWhat it looks forWhat it suggests
Cache expiredA pause longer than the cache TTL, then the prompt sent again uncachedThe cache warning; whether a 1 hour TTL would have paid off here
Model switchThe same loop changing model and losing its cacheSwitching between activities instead
DenialsThe same command denied twice or moreA permissions.allow rule for read-only commands, a permissions.deny rule for destructive ones (never allow)
FailuresThe same command failing three times or more, this session and earlier onesThe right command in CLAUDE.md, or a hook
Repeated command, repeated fetchThe same command 5 times, the same URL 3 timesA script, skill or slash command; a local copy of the docs
Slow toolA tool whose 95th percentile is over a minuteRunning it in the background
Subagent modelA subagent that only read files, on a model other than HaikuThe Explore type or model: haiku
ContextContext over 80%/compact, or loading less every session
First requestA first request over 30k tokens, or well over this project's medianReviewing CLAUDE.md, MCP servers and skills
Unused MCP, unreliable MCPA server in .mcp.json not called in 5 sessions; one that fails or is slowDisabling 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:

SettingWhat it does
Notify when an activity endsA 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 expiresOne 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 historyTurns on the Insights tab and keeps counts per project between sessions (below)
Suggest the AI reviewShows the AI review in the Insights tab
Save the export automatically when an activity endsWrites 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 badgeKeeps the session's badge under the prompt even with the browser view closed. See Browser view
Delete the stored historyDeletes every project's history
Languageauto, 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.

In the browser

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:

ColumnWhat it is
NewInput 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.
PromptNew + Cache↓ + Cache↑: everything the request sent. It grows with the conversation.
HitCache↓ ÷ 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).

Requirements

You needCheck
Claude Code 2.1.287 or later, where mods existclaude --version
A terminal, or the Code tab of the Desktop app, to see the panesee Where it draws
For the browser view only: Node.js 20 or later on the PATHnode --version

Install

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:

  1. /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.
  2. /radar opens the pane.

To try it without installing, clone this repository and start Claude Code with claude --plugin-dir ./ns-session-radar.

Use

CommandWhat it does
/radarOpens or closes the pane. Works while Claude is working.
/radar-exportWrites 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-webOpens the radar in your browser (see Browser view); /radar-web stop ends it
/radar-configOpens the pane on the Config tab
/session-radar:radar-reviewThe AI review of an export. Uses tokens

Inside the pane:

KeyWhat it does
1 to 4Switch tab: Home, Activities, Insights, Config
Tab, ↑ ↓, EnterIn Activities, move between activities and open one
hShow or hide how to read the tab
wOpen the browser view, same as /radar-web
eExport, same as /radar-export
xClear what has been recorded: press it twice within 3 seconds
dMove the radar below the transcript, or back beside it
EscClose 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.

AI review

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.

Browser view

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:

  • the tab's title, ▲ K7QF · my-app · Session Radar, and its icon, the mark in the session's colour;
  • a band in that colour across the top, and the badge in the header with the project folder and the model; hover it for the full path;
  • when the session or its server ends, the title turns into ✕ 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:

  • pick one, and every view shows that session;
  • pick 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:

  • It listens on 127.0.0.1 only, on a port the system picks, and refuses any request whose Host or Origin is not that address.
  • The data needs a random token that only the URL you were given carries; sending data needs a second token only the mod knows. The page loads nothing from elsewhere (a Content-Security-Policy of 'self').
  • Where it listens and its two tokens are kept in session-radar/hub.json under your Claude Code configuration directory, readable by your user alone, so your other sessions can find it.
  • It holds only the last snapshot of each session and the last 10 minutes of memory samples, in memory. It ends with /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 it draws

Where you run Claude CodeRecordingPane and spinner
claude in a terminalyesyes
Code tab of the Desktop appyesyes
VS Code extension's chat panelyesno
claude -p and the Agent SDKyesno
Cloud sessionsonly if the plugin reaches the sessionno

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.

How it knows

Claude Code eventWhat the radar takes from it
tool.callTool 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.spawnThat a subagent started, and its type
turn.stepOne 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.completeWhere 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.jsonWith 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.

Update and uninstall

/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.

Troubleshooting

SymptomLikely causeFix

| /radar is not a command | Claude Code older than 2.1.287, or the plugin is not loaded | claude --version; then /reload-plugins

Source 3 files
hooks/register.js 2221 lines
1// 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}
1200
hooks/insights.js 479 lines
1// 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}
479
types/index.d.ts 34 lines
1// 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