ccdeck 3.44.0 → 3.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/bin/cli/startup.js +4 -3
  4. package/dist/web/assets/BrowserWatchModal-CoX9FIUE.js +1 -0
  5. package/dist/web/assets/IntegrationModal-B1ypkK-E.js +5 -0
  6. package/dist/web/assets/RadioPanel-biw_BbZq.js +1 -0
  7. package/dist/web/assets/TrafficRadar-nhtFoYVv.js +8 -0
  8. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-Bd-JRAa4.js} +1 -1
  9. package/dist/web/assets/app-BlRY6EME.css +1 -0
  10. package/dist/web/assets/app-DhGvSH7o.js +319 -0
  11. package/dist/web/assets/{index-BQbOfxbL.js → index-DmENqVdt.js} +1 -1
  12. package/dist/web/index.html +3 -3
  13. package/package.json +1 -1
  14. package/release-notes.json +48 -0
  15. package/src/server/account-notify.mjs +68 -6
  16. package/src/server/account-watch.mjs +61 -2
  17. package/src/server/claude-accounts.mjs +45 -19
  18. package/src/server/claude-login.mjs +111 -0
  19. package/src/server/cswap-admin.mjs +12 -3
  20. package/src/server/deck-prefs.mjs +15 -5
  21. package/src/server/feature-use.mjs +11 -1
  22. package/src/server/forge-http.mjs +514 -0
  23. package/src/server/forge-public-fetch.mjs +228 -0
  24. package/src/server/forge-routes.mjs +154 -0
  25. package/src/server/github-api.mjs +1369 -0
  26. package/src/server/gitlab-api.mjs +1271 -0
  27. package/src/server/index.mjs +6 -2
  28. package/src/server/installer.mjs +24 -7
  29. package/src/server/integrations/cache.mjs +42 -0
  30. package/src/server/integrations/errors.mjs +167 -0
  31. package/src/server/integrations/forge.mjs +76 -0
  32. package/src/server/integrations/jira.mjs +1026 -0
  33. package/src/server/integrations/providers.mjs +11 -0
  34. package/src/server/integrations/proxy.mjs +314 -0
  35. package/src/server/integrations/registry.mjs +142 -0
  36. package/src/server/integrations/routes.mjs +498 -0
  37. package/src/server/integrations/store.mjs +226 -0
  38. package/src/server/lan-deck.mjs +7 -0
  39. package/src/server/lan-elsewhere.d.mts +30 -0
  40. package/src/server/lan-elsewhere.mjs +137 -0
  41. package/src/server/login-service.mjs +2 -2
  42. package/src/server/org-telemetry.mjs +173 -0
  43. package/src/server/pinned-build.mjs +3 -0
  44. package/src/server/request-gates.mjs +19 -0
  45. package/src/server/skill-hook.mjs +28 -22
  46. package/src/server/system-metrics.mjs +4 -0
  47. package/src/server/tool-audit-routes.mjs +4 -0
  48. package/src/server/traffic-radar-config.mjs +33 -0
  49. package/src/server/traffic-radar-platform.mjs +84 -17
  50. package/src/server/traffic-radar-service.mjs +6 -3
  51. package/src/server/traffic-radar.mjs +32 -9
  52. package/src/server/usage-day.mjs +2 -0
  53. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  54. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  55. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +0 -8
  56. package/dist/web/assets/app-CQIstkMF.js +0 -318
  57. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
package/README.md CHANGED
@@ -98,7 +98,10 @@ The deck opens on these eight pictures the first time it runs — they are the w
98
98
  | **Live DAG** | Nodes are agents and edges are spawns; each agent's latest tool calls sit beside its node and light up while they run. In-flight edges animate, settled ones dim. |
99
99
  | **Both providers, one canvas** | Claude Code through hooks, Codex through its rollout log. The model chip (`Opus 5`, `GPT-5.5`) tells them apart. Codex child sessions are connected when their rollout records an explicit parent. Each thread keeps its own tool calls and token totals. |
100
100
  | **Cost and quota, live** | Spend per model and per session, plus Claude and Codex quota windows as they refill. |
101
- | **Tool activity** | Which tools and commands your Claude Code and Codex agents ran on this machine, over the last day or up to 90 days: how many calls, how long they took, which ones fail — and, on its Skills tab, which skills they used, how often and when last. Kept on this machine and nowhere else. |
101
+ | **Tool activity** | Which tools and commands your Claude Code and Codex agents ran on this machine, over the last day or up to 90 days: how many calls, how long they took, which ones fail. Kept on this machine and nowhere else. |
102
+ | **Skill activity** | Which skills your agents used, whether you typed them or the agent called them, how often and when last — from its own button on the right stripe, below Tool activity. Kept on this machine and nowhere else. |
103
+ | **Jira beside your agents** | Connect Jira Cloud or Jira Server / Data Center in **Settings › Integrations**, and a Jira control joins the left stripe (`I`). It opens a board's backlog laid out the way Jira's Backlog page lays it out — active sprints first, then the future ones and the backlog, in Jira's rank order, with the board's own quick filters, *Only my issues* and *Hide done* — and an issue's description, comments, attachments and sub-tasks beside the list. Read-only: it never changes anything in Jira. |
104
+ | **GitHub and GitLab beside your agents** | Connect GitHub, or GitLab.com or your own GitLab, with a personal access token in **Settings › Integrations**, and its control joins the left stripe. It opens the screens you already work through on GitLab — Branches, Merge requests (Pull requests), Pipelines (Actions) and Issues, for one project or across them — with the merge request, branch, pipeline with its jobs and the failed job's last log lines, or issue beside the list. Paste a link to open it. Read-only: it never comments, approves, merges or re-runs anything. |
102
105
  | **Double-click to inspect** | Any node opens its prompt, tool calls, token usage and timing in the side panel. A single click selects it and frames its session. |
103
106
  | **Survives restarts** | Events are appended to this platform's log directory (see `--history` below) and replayed on open. |
104
107
  | **Accounts without a terminal** | Sign a new Claude account in, move one or your whole set to another machine, rename, reorder, remove — from the panel. |
@@ -131,7 +134,7 @@ Everything the page lets you set apart from the music — the theme, the text si
131
134
 
132
135
  **Radio**, on the right sidebar beside the character (in **More** on a phone), opens a responsive Radio Explorer with a large interactive map, station discovery and a persistent player. Filter by country, sound or language, save favorites, return to recent stations, or choose **Surprise me**. Your existing Claude FM, Lofi Girl and custom streams are under **Your streams**. Select a station to inspect it, then press Play to listen; closing Radio or hiding the character keeps it playing. Favorites, history and the last catalogue are saved in this browser. Control music directly in Radio Explorer; character settings are in **Settings › Appearance**.
133
136
 
134
- **Tool activity**, on the stripe on the right below Usage history (in **More** on a phone), is a page of which tools and commands the Claude Code and Codex agents on this machine used. For the last 24 hours, 7, 30 or 90 days, and both CLIs or one, it lists each tool's calls, its total and usual time and its failures, sortable by any of them and searchable, under up to three findings worth a look: the tool that takes the most time, one that fails often, the slowest. A row opens that tool's details beside the list: who used it, its calls per day, its latest and slowest calls. A shell command counts per operation — `git status`, `git push` and `npm run build` are rows of their own — and an MCP tool under its own name. A call's time runs from the agent starting the tool to its result, so it includes any wait for your approval, and Claude Code and Codex time their tools differently. Its **Skills** tab, beside **Tools** at the top, lists the skills those agents used, ranked by uses, with when each last ran: a skill Claude calls, a slash command you type (only when the deck is started with `AGENTS_DECK_SKILL_HOOK=1` and every Claude Code on the machine is 2.1.116 or newer — see [How it works](#how-it-works)), a skill you pick in Codex, and one Codex picks itself, detected when it reads that skill's instructions. The same command run again in a session with the same arguments, such as a `/loop` tick, is a repeat and is left out of the count. A skill opens its details: its uses per day, the projects it ran in, and its latest uses, each linking to its session while that is still on the canvas. It counts what the deck captures from the first time it runs at 3.42.0 or later — earlier sessions are not read back — and it is kept apart from the canvas: Clear empties the canvas and leaves the tool history. What it keeps, and where, is in [What it touches](#what-it-touches).
137
+ **Tool activity**, on the stripe on the right below Usage history (in **More** on a phone), is a page of which tools and commands the Claude Code and Codex agents on this machine used. For the last 24 hours, 7, 30 or 90 days, and both CLIs or one, it lists each tool's calls, its total and usual time and its failures, sortable by any of them and searchable, under up to three findings worth a look: the tool that takes the most time, one that fails often, the slowest. A row opens that tool's details beside the list: who used it, its calls per day, its latest and slowest calls. A shell command counts per operation — `git status`, `git push` and `npm run build` are rows of their own — and an MCP tool under its own name. A call's time runs from the agent starting the tool to its result, so it includes any wait for your approval, and Claude Code and Codex time their tools differently. Its **Skills** tab, beside **Tools** at the top, lists the skills those agents used, ranked by uses, with when each last ran: a skill Claude calls, a slash command you type (when every Claude Code on the machine is 2.1.116 or newer — see [How it works](#how-it-works)), a skill you pick in Codex, and one Codex picks itself, detected when it reads that skill's instructions. The same command run again in a session with the same arguments, such as a `/loop` tick, is a repeat and is left out of the count. A skill opens its details: its uses per day, the projects it ran in, and its latest uses, each linking to its session while that is still on the canvas. It counts what the deck captures from the first time it runs at 3.42.0 or later — earlier sessions are not read back — and it is kept apart from the canvas: Clear empties the canvas and leaves the tool history. What it keeps, and where, is in [What it touches](#what-it-touches).
135
138
 
136
139
  No config file. No account. What your sessions contain — your prompts, the replies, the files they touch — is never reported anywhere. The deck does send usage reports, on by default: its version, your system, your IP address and a device fingerprint, rough counts such as how many sessions ran in a day, and the errors it hits. It has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start.
137
140
 
@@ -328,7 +331,7 @@ SubagentStart · SubagentStop · Stop · StopFailure · SessionEnd · Notificati
328
331
 
329
332
  `StopFailure`, which Claude Code sends instead of `Stop` when a turn ends on an API error, is added only when every Claude Code the deck finds on disk is 2.1.78 or newer, read from its install rather than by running it. Older ones did not know the event, and before 2.1.101 an event name Claude Code did not know made it ignore the whole `settings.json`. When the deck cannot tell which version is installed, it leaves the entry out.
330
333
 
331
- `UserPromptExpansion`, which lets Tool activity's Skills tab count the slash commands you type, is off by default, and there is no switch for it in Settings. The deck adds it only when you start it with `AGENTS_DECK_SKILL_HOOK=1` (any other value is off), and then only when every Claude Code the deck finds is 2.1.116 or newer, the first that knows the event. An install that does not say its version is asked `claude --version`; one that still does not say counts as too old. Until 2.1.101 a hook event Claude Code did not know made it ignore the whole `settings.json` without saying so, so the deck would rather count no typed commands than risk that. Turned on, it writes that one entry, and the first time it does, it keeps the `settings.json` it replaced as `agent-dag/settings.before-skill-hook.json` beside the hook, readable only by you. Off, it changes nothing in `settings.json`, with one exception: start the deck without the variable, or with an older Claude Code, after a start that added the entry, and that entry is taken back out. Nothing else is touched, and a `UserPromptExpansion` hook of your own stays. What the Skills tab records — the skill's name, the session it ran in and its project folder's name — stays on this machine (see [What it touches](#what-it-touches)).
334
+ `UserPromptExpansion`, which lets Skill activity count the slash commands you type, is added only when every Claude Code the deck finds is 2.1.116 or newer, the first that knows the event. An install that does not say its version is asked `claude --version`; one that still does not say counts as too old. Until 2.1.101 a hook event Claude Code did not know made it ignore the whole `settings.json` without saying so, so the deck would rather count no typed commands than risk that; when it leaves the entry out for that reason, the start-up report says why and Skill activity says typed commands are not counted. The first time it writes the entry, it keeps the `settings.json` it replaced as `agent-dag/settings.before-skill-hook.json` beside the hook, readable only by you. There is no switch for it in Settings: start the deck with `AGENTS_DECK_SKILL_HOOK=0` to leave it out, and a start like that, or one with an older Claude Code, after a start that added the entry takes that entry back out. Nothing else is touched, and a `UserPromptExpansion` hook of your own stays. What Skill activity records — the skill's name, the session it ran in and its project folder's name — stays on this machine (see [What it touches](#what-it-touches)).
332
335
 
333
336
  Each one fires the bundled `hook.js`, which POSTs the event JSON to the running server. The hook is fire-and-forget with a 1-second timeout: if the deck is not running, your session is not slowed down and nothing fails.
334
337
 
@@ -342,12 +345,16 @@ Opening Radio asks the public [Radio Browser](https://www.radio-browser.info/) d
342
345
 
343
346
  It never steers an agent or edits your code, but it is not read-only either — besides the hook entry and its own event log, it manages the two tools it leans on, and it refreshes the Codex token it reads quota with, rewriting `~/.codex/auth.json` the way `codex` itself does. It also reads your browser's history, because Browser Watch is on unless you switch it off; that is its own section below, because it is the one thing here that is about you rather than about an agent.
344
347
 
345
- Telemetry Radar's flag switches write to `~/.claude/settings.json` too: each puts the flag you set — `CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_LOG_USER_PROMPTS` and the other telemetry and content flags Radar lists — in that file's `env` block, as `"1"` or `"0"`. Switching a flag off writes `"0"` rather than removing it, and `--uninstall`, which removes ccdeck's hook entries, leaves these values as you set them. Starting Radar's monitoring writes `~/.agents-deck/radar-monitor-<port>.json`: the opt-in and the destinations a deck on that port resumes, never packets or decoded messages; turning on automatic monitoring writes `radar-settings-<port>.json` beside it, the switch and the collectors it found. And the quota readings the deck takes are kept in `quota-history.json`, in its data directory (see [Uninstall](#uninstall)), for 35 days and up to 2 MB, under a hash of the account rather than its address, for the Usage panel's quota history.
348
+ Telemetry Radar's flag switches write to `~/.claude/settings.json` too: each puts the flag you set — `CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_LOG_USER_PROMPTS` and the other telemetry and content flags Radar lists — in that file's `env` block, as `"1"` or `"0"`. Switching a flag off writes `"0"` rather than removing it, and `--uninstall`, which removes ccdeck's hook entries, leaves these values as you set them. Starting Radar's monitoring writes `~/.agents-deck/radar-monitor-<port>.json`: the opt-in and the destinations a deck on that port resumes, never packets or decoded messages; turning on automatic monitoring writes `radar-settings-<port>.json` beside it, the switch and the collectors it found. And the quota readings the deck takes are kept in `quota-history.json`, in its data directory (see [Uninstall](#uninstall)), for 35 days and up to 2 MB, under a hash of the account rather than its address, for the Usage panel's quota history. What each Claude organization's settings turn on for telemetry is kept in `org-telemetry.json`, in the same data directory: per organization, its name, the level, the content flags that are on, the collector's scheme, host and port, and when the deck read them, never a path, a header or a credential.
346
349
 
347
350
  Tool activity keeps its history in `tool-audit/`, in the same data directory: a file a day with a line per tool call — Claude Code or Codex, when it started and ended, whether it failed, and the tool's name or, for a shell command, its operation (`git push`, `npm run build`) — with a line per skill used, holding the skill's name, the session it ran in and the name of its project folder, and a `settings.json` holding how long to keep it, when it started counting, and a random id for this device that nothing sends anywhere. The rest of a command line, the tool's or the skill's arguments and output, and paths are not saved, nor a tool call's session id, and nothing in the folder leaves the machine. It keeps 90 days unless you pick 30 or 60 under **Keep history** in **Settings › Data › Tool history**, which also shows the space it takes and has **Clear history…**; older days are deleted as they fall out. `--no-persist` does not stop it — that flag is about the events log — and `--uninstall` leaves it, `--purge` too (see [Uninstall](#uninstall)).
348
351
 
349
352
  What does go out is short and ordinary: a ~20-byte version check against the npm registry (plus one small request to confirm a version it has not seen before), installs and daily version checks for the two tools the deck manages (claude-swap from PyPI, ccusage from npm), on an Apple Silicon Mac whose sensors stay silent one release lookup and one binary download from GitHub for `macmon`, and, while the page is open or one of the two quota notifications is on, quota reads to Anthropic and OpenAI signed with your own credentials — that is where those numbers live. While the page is open, and while the desktop app has a session running or waiting, it also reads Anthropic's and OpenAI's public status pages (`status.claude.com`, `status.openai.com`), unsigned, about every three minutes, and once a minute at most while a page is not answering, so an outage on their side shows as a chip in the topbar instead of looking like a fault on yours. While the machine panel is open, on a machine with Claude Code, it times the way to Claude every thirty seconds — a DNS lookup of `api.anthropic.com` and a TCP handshake on port 443, with nothing sent over it — for the panel's latency reading. While the Browser Watch panel is open, it looks up the address of Claude's browser relay, `bridge.claudeusercontent.com`, about every thirty seconds — a DNS query to your resolver — to tell whether a running browser holds a connection to it. The [desktop app](#desktop-app) also asks this repository's GitHub releases for its own updates. With **Local network** on, which it is unless you switch it off, the deck also announces itself to other decks on your local network over UDP (port 45317) and pairs with the ones that answer — see [Local network](#local-network). The deck also sends usage reports to `api.ccdeck.dev`: an install event (naming the ccdeck.dev page the command was copied from, when the command says so with `--ref`, which the site's copy buttons add; a page name and nothing else, sent once), an update event (saying how the new version arrived: the deck's own update, the desktop app's updater, npx, npm or a source checkout), one "active" a day, one "uninstall" when you run `ccdeck --uninstall` (which asks, at a terminal, one skippable question about why, from a short list), one "activated" when a new install's first session arrives (how long after the install, in rough buckets such as "under five minutes", and whether it was a Claude or a Codex session), one "rated" if you answer the one question the deck asks about itself after about a week of use — "How useful is ccdeck to you?", 0 to 10, which you can skip — with the number you picked and how many days you had used the deck, as a range (nothing is sent for a question you put off or leave), and the errors it runs into, with every path outside ccdeck's own code, project folder names, the machine name in every web address, every IP address and the machine name a network error carries (the loopback, such as `127.0.0.1:4317`, stays, so a local port error still reads as one), email addresses and keys scrubbed out (a line of ccdeck's own code keeps its place in the package, such as `ccdeck/src/server/tail.mjs:42`, or in the page, such as `<deck>/assets/index-abc.js:1:2345`, so the error still says where it happened). Each carries the version, your OS and CPU architecture, the channel (desktop, npm or a source checkout), your IP address and the machine's rough shape — the number of CPU cores, the amount of RAM, the locale, the shell, the terminal, and the Claude Code and Codex versions. The install, update, active, activated, rated and uninstall reports also carry the runtime and a device fingerprint (a stable hashed machine id); an error report carries neither. The first four also say whether the Claude hooks went in and Codex is being watched, with the daily "active" also carrying how many sessions, subagents and projects you ran on the last day you used the deck, that day's date, and which of the deck's own features you used that day — fixed names such as "usage history" or "account switch", a yes for each and nothing about what you did with them — plus how the deck held up and what it runs with: how long this run took to start, that day's peak memory and how many events it took in, each as a rough bucket such as "under two seconds" or "256–512 MB", the Claude and Codex plan as its category only (such as "Max 5x" or "Plus", read from the file each CLI keeps beside its login, and nothing else from it), and how many Claude accounts and paired machines the deck holds, as counts. The fingerprint is a one-way hash of stable machine traits, never those traits in the clear; everything else is coarse counts and short tokens — no path, no project name, no free text. These reports are no longer anonymous: the install id is random, but the fingerprint follows this machine and the server records the IP the report arrives from. What still never leaves is your sessions, prompts, files, project names and paths. The deck has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start. While the deck runs it also sends a tiny heartbeat every few minutes, the install id and nothing else, so the count of decks running right now, and how many hours a day a deck stays open, can be seen. **Send feedback** (the topbar's Feedback button; on a phone, under More) sends what you write, and any screenshots and contact you add, to the same place, with the ccdeck version and your system (such as `darwin-arm64`), which the dialog names before you send, and, while the usage reports are on, the same device fingerprint they carry, which it does not show; `AGENTS_DECK_NO_REPORTS=1` keeps the fingerprint home and the feedback still goes. As with a report, the server records the IP address it arrives from. The people who make ccdeck read it there and may open a public issue from it, never with the contact you gave or your screenshots. `AGENTS_DECK_NO_INSTALL=1` turns off everything but the quota reads; `AGENTS_DECK_NO_DOWNLOAD=1` is the narrower version — no `uv` binary is fetched, the managed installs stay.
350
353
 
354
+ **Settings › Integrations** keeps each connection in `integrations/`, in the deck's data directory: `integrations.json` holds its name, its address and the account it signed in as, and `integrations-secrets.json`, which only you can read, holds its token. The token goes to that connection's own Jira and nowhere else — for a scoped Atlassian Cloud token, `api.atlassian.com` as well — never to the page, never into a report or a log, and never to another deck on the local network. The Jira panel reads from Jira only while it is open: when you open a board read more than five minutes ago, or press Refresh. What it reads stays in memory; only the panel's layout and filters are remembered, in this browser. Avatars and issue icons come through the deck, which sends the token only to Jira's own address. Disconnecting deletes the connection and its token.
355
+
356
+ A GitHub or GitLab connection is kept the same way, and its token goes only to that forge's API — GitHub's at `api.github.com`, GitLab's at the address you typed (`gitlab.com` unless you set your own). The deck reads that forge only while its panel is open: opening the panel asks again for what is more than two minutes old, **Refresh** asks for the rest, and nothing is polled in the background. It only reads: it never comments, approves, merges, re-runs a job or touches a branch, and the token help asks for read access and nothing more (`repo` and `read:org`, or read-only fine-grained permissions, on GitHub; `read_api` on GitLab). A failed job's log is read only when you press **Show log**, and only its last lines. What it reads stays in the open page and is not written to disk; the project, tab and filters you last chose are remembered in this browser. Avatars and the pictures in a description reach the page through the deck, which fetches them only from the forge's own image hosts — GitHub's `githubusercontent.com` hosts without the token, your GitLab instance, or Gravatar without the token — and passes on PNG, JPEG, GIF and WebP only. Links in the panel open the forge in your browser.
357
+
351
358
  Local network, Claude accounts, Usage and the empty canvas each carry a small **Watch · 30 s** link to their 30-second video on [ccdeck.dev](https://ccdeck.dev/guides/). Opening one is your click, in your own browser; the deck itself sends nothing for it.
352
359
 
353
360
  ## Browser Watch
@@ -370,20 +377,25 @@ The Accounts panel reads the store [claude-swap](https://github.com/realiti4/cla
370
377
 
371
378
  The deck installs that package itself, so it installs it **bounded**: `claude-swap~=0.26`, which is `>= 0.26, == 0.*`, and when PyPI can be reached the exact version it resolved rather than the range. What `cswap --version` reports afterwards has to be that version, or the deck says so and leaves the panel dark rather than driving a copy it cannot account for — this is the tool that holds your Claude logins. The daily upgrade is bounded by the same specifier, and what it left behind is written to `~/.agents-deck/cswap-upgrade.json`.
372
379
 
380
+ The active account is the one Claude Code is signed in to, as `cswap list` shows it, even after a `/login` made outside the deck; when that login is not one of your accounts, the panel says so above the list, with **Add account**.
381
+
373
382
  **Switch** on an account's row makes it the active one. New sessions start on it; sessions already running are not restarted, and pick it up on their next message — up to about 30 seconds later on macOS. Because every session on the machine shares that one login, the deck will not switch to an account whose saved login claude-swap has already seen refused: it asks claude-swap to read the account first when a read is due, and if the login has expired the row says so, with **Sign in again**, and nothing moves. When it cannot tell — offline, or claude-swap does not answer in time — the switch goes ahead. **Auto-switch**, off until you turn it on, does the same without a press: about once a minute the deck runs claude-swap's own engine, which, once the active account passes your threshold (90% unless you choose another) of its 5-hour or 7-day window, moves Claude Code to the account with the most room left. Every account keeps its own limits; when no other account is under the threshold, nothing switches. Auto-switch also moves Claude Code off an account whose login has expired, once Claude Code has signed it out and claude-swap's stored copy of it no longer works either, to the account with the most room under the threshold that claude-swap has not set aside and no local `cswap run` session is using; the expired one stays in the list, waiting for you to sign it in again. When Claude Code sessions stop on an expired login or a usage limit, Auto-switch runs once right away instead of waiting for its next minute, and the cards say `Stopped · login expired` or `Stopped · usage limit` until the session does something again: type `continue` in each one once a working login is active. A session running in the background (`claude --bg`, agent view) gets **Continue** on its card instead, enabled once the active account's usage reading shows a working login with room since the stop: it runs Claude Code's own [`claude stop` and `claude --resume <session> --bg`](https://code.claude.com/docs/en/sessions) in the session's folder, and the card says how that went. **Continue stopped background sessions**, in **Settings › General**, off until you turn it on, presses it for you, once per stop; a session that stops again right after is left for you. **Custom…**, last in the threshold picker, takes any whole percent from 50 to 99 and refuses anything else under the field without saving it; the threshold is claude-swap's own setting, kept on this machine.
374
383
 
375
384
  Two choices change where Auto-switch goes, and do nothing while it is off. **Next account**, under the switch, is **Most room left** — claude-swap's own pick, and the default — or **Soonest weekly reset**: the account whose 7-day window resets soonest, as long as that is within 95 hours, so quota that is about to reset is used first. With it, the deck also moves below the threshold, to an account that resets at least an hour sooner than the one in use, and never sooner than half an hour after the last move. **Set as home**, in an account's **⋯**, marks the row with ⌂: Auto-switch takes you there, does not move you off it before the threshold, and comes back once its 5-hour window has reset. Only an account comfortably under the threshold, read in the last 15 minutes, signed in, in rotation, not set aside by claude-swap and not in use in a local `cswap run` session is moved to. An account reported as active on another paired device remains eligible for Home and Soonest weekly reset. If Home has stale readings, Auto-switch asks claude-swap to collect before choosing a weekly alternative; if Home is still unavailable afterward, it uses the alternative. A switch you make yourself is left alone until that account reaches the threshold, or until you set a home or pick a rule again. When no account fits either rule, claude-swap decides, as before. Both choices are kept on this machine.
376
385
 
386
+ **Telemetry** under an account's name says what that account's organization turns on for Claude Code's telemetry export, before you switch to it: `Telemetry · usage data`, or, in the warning colour, `Telemetry · prompts, tool details` when content goes with it. The whole sentence — the organization, what is sent, where to, and how long ago the deck saw it — is in the line's tooltip and its screen-reader description. A Team or Enterprise organization can turn this on for every member, and Claude Code applies it without asking in the VS Code panel, the desktop app and `claude -p`. The deck learns it from `~/.claude/remote-settings.json`, which describes whichever organization last fetched it, so it records the file only once it was written after claude-swap's last switch and the live login is that account's; an organization it has not seen that way yet shows nothing, which means unknown, not off. While Auto-switch is on, the tooltip also says how to keep it off such an account: **Hold out of rotation**, in the account's ⋯ menu.
387
+
377
388
  **Account capacity**, the gauge in the panel's header, says how many accounts are ready and what their 5-hour and 7-day windows have left. Its **Reset calendar** view lays out today and the next six days, with each account's 7-day reset on the day it falls, and marks the readings that need refreshing.
378
389
 
379
- **Account notifications** are four switches in **Settings › Notifications** (the gear in the topbar, or `Cmd+,`; `Ctrl+,` on Windows and Linux), each on its own and none of them tied to **Notifications while closed**: they are sent whether the deck is open or not, because nothing on the page says them. The desktop app shows them as ccdeck, without a sound.
390
+ **Account notifications** are five switches in **Settings › Notifications** (the gear in the topbar, or `Cmd+,`; `Ctrl+,` on Windows and Linux), each on its own and none of them tied to **Notifications while closed**: they are sent whether the deck is open or not, because nothing on the page says them. The desktop app shows them as ccdeck, without a sound.
380
391
 
381
- - **Account auto-switched**, on unless you turn it off. When Auto-switch moves Claude Code to another account, one notification says where to and, when the engine's own reading shows it, why: `Switched to work · personal@example.com reached 90%`, `Back on work · your home account` or `Switched to side · weekly limit resets in 1d 7h`, with an account's alias when it has one. A switch away from an expired login says so: `Switched to work · personal@example.com's login expired, sign in again to use it`; and when no other account is ready to take over, that is said once rather than every minute. A switch you make yourself is not announced.
392
+ - **Account auto-switched**, on unless you turn it off. When Auto-switch moves Claude Code to another account, one notification says where to and, when the engine's own reading shows it, why: `Switched to work · personal@example.com reached 90%`, `Back on work · your home account` or `Switched to side · weekly limit resets in 1d 7h`, with an account's alias when it has one. A switch away from an expired login says so: `Switched to work · personal@example.com's login expired, sign in again to use it`; and when no other account is ready to take over, that is said once rather than every minute. When the account it moved to belongs to an organization the deck has seen turn telemetry on, a second line says what it sends from the next session, and where: `Acme's telemetry is on for new sessions: it sends your prompts and tool details to 192.0.2.16:4317`. A switch you make yourself is not announced; its row's `Now active` note says the same.
382
393
  - **Quota at 90% and 100%**, off until you turn it on. When a window of the Claude or Codex account you are on reaches 90%, and once more at 100%: Claude's 5-hour, 7-day and per-model windows, and every Codex window the Usage panel shows, the extra limits included — `5-hour usage reached 90% · resets in 2h 14m`. Only the account in use is watched, not every account you have stored. When reaching it is what makes Auto-switch move the account, the switch notification says it and this one is not sent, unless the switch notification is off.
383
394
  - **Quota reset**, off until you turn it on. When one of those windows that had reached 90% starts over: `5-hour window available again`. A window that never got that high resets without a word.
384
395
  - **Sessions stopped on login or limit**, on unless you turn it off. When Claude Code sessions stop on an expired login or a usage limit, one notification for all of them says how many stopped and to type `continue` in each. When the Auto-switch run that follows moves the account, the switch notification says it instead, so one incident is one notification.
396
+ - **Login active on another deck**, on unless you turn it off. When a paired deck on Local network says it is on the Claude login this deck is on, one notification names it: `Also active on iMac. When one machine renews this login, the other is signed out — use a different account on one of them.` It is said once, until one of the two decks moves off that login or the other stops saying it is on it, and the account's row warns **Also active on** for as long as that lasts.
385
397
 
386
- Each is said once per window. The deck writes what it has said to `account-notify.json`, beside its settings, so a restart does not say it again, and nothing said about one account counts for another. With either quota switch on, the deck reads the quota of the accounts in use about every two minutes even while no page is open — the same reads, through the same caches and limits, as the Usage panel's. `AGENTS_DECK_NO_NOTIFY=1` keeps all four off.
398
+ Each is said once per window. The deck writes what it has said to `account-notify.json`, beside its settings, so a restart does not say it again, and nothing said about one account counts for another. With either quota switch on, the deck reads the quota of the accounts in use about every two minutes even while no page is open — the same reads, through the same caches and limits, as the Usage panel's. `AGENTS_DECK_NO_NOTIFY=1` keeps all five off.
387
399
 
388
400
  **`+` → Sign in** runs `claude auth login`, shows you the link, and hands the result to `cswap add`. The sign-in usually completes by itself once you approve it in the browser; only when the page shows a code — as it does on a deck opened from another machine — is there one to paste back into the dialog. The account you were using **stays active** — signing in replaces the live credentials, so the previous one is switched back the moment the new one is recorded. A pasted code goes straight into the CLI's stdin on this machine; it is never stored, logged, or sent anywhere else.
389
401
 
@@ -459,7 +471,7 @@ Four pictures, which are also the guide the section opens from `See how it works
459
471
 
460
472
  **What is shared, and with whom.** Nothing until you tick a login, and then only that login, and only with paired decks. A login ticked on one deck arrives on every deck paired with it: added where it is missing, repaired where it has expired, and never in place of one that still works. The deck asks the machines it finds, and a machine that is asked waits for somebody there to press `accept` unless its `Say yes to every deck that asks` switch, in the same dialog, is on. That switch starts on, so your own machines pair without a press. It also means a deck the switch pairs can add the logins ticked on it to this machine, and Auto-switch, if it is on, may later move Claude Code to one of them — so on a network you do not own, turn the switch off. What still takes a person is the tick: a paired deck is handed nothing until somebody ticks a login here. A login that arrives from a deck somebody here accepted or paired by invite is ticked here too, so this machine can repair the next one; a deck the switch paired is handed only the logins a person ticked on this deck, never the ones that were ticked by arriving, so untick and tick such a login again to hand it to those decks as well — and what a deck the switch paired brings here is not ticked here at all. What crosses the wire is the same live credential a share carries, sealed to the deck it is addressed to, so treat the pairing decision as the moment that matters. Unpairing stops future rounds; a login already copied stays where it went.
461
473
 
462
- **The login a machine is signed in to stays on that machine** while it is in use there, ticked or not. A Claude login renews once, so two machines on one copied login race at each renewal and the one that renews second is signed out (“Login expired”). The dialog marks it **in use here · not sent** when it is ticked, a paired deck reads it as **in use there** (or does not see it, while this deck hides which account it is on), and it goes out like any other ticked login as soon as this machine switches to another account. A paired deck that already holds a copy of that login, from before this machine switched to it, can still renew it first and sign this machine out. To hand it out anyway, turn on **Send the login this deck is using too** in the same dialog.
474
+ **The login a machine is signed in to stays on that machine** while it is in use there, ticked or not. A Claude login renews once, so two machines on one copied login race at each renewal and the one that renews second is signed out (“Login expired”). The dialog marks it **in use here · not sent** when it is ticked, a paired deck reads it as **in use there** (or does not see it, while this deck hides which account it is on), and it goes out like any other ticked login as soon as this machine switches to another account. A paired deck that already holds a copy of that login, from before this machine switched to it, can still renew it first and sign this machine out. When a paired deck says it is on the login this deck is using, that account's row warns **Also active on** and names the deck, with one desktop notification, so one of the two machines can move to another account; a deck that hides which account it is on, or is on one it does not share with this one, says nothing, and nothing is shown. To hand it out anyway, turn on **Send the login this deck is using too** in the same dialog.
463
475
 
464
476
  On a Mac, **cannot share here** is different from **expired here**. It means this ccdeck process cannot read the Keychain copy, so that machine is not used as a credential source and another deck does not keep trying to “repair” the same healthy slot every minute. A LAN import is checked after it lands too; if the receiving Mac still cannot read the Keychain, the round reports that problem instead of claiming the login was repaired.
465
477
 
@@ -609,7 +621,7 @@ Environment:
609
621
  | `AGENTS_DECK_CSWAP` | Full path to `cswap`, when it lives somewhere unusual |
610
622
  | `AGENTS_DECK_CLAUDE` | Full path to the `claude` CLI |
611
623
  | `AGENTS_DECK_CCUSAGE` | Full path to your own `ccusage`, used ahead of everything else |
612
- | `AGENTS_DECK_SKILL_HOOK=1` | Add the `UserPromptExpansion` hook, so Tool activity's Skills tab counts the slash commands you type — only where every Claude Code found is 2.1.116 or newer. Off by default, and only `1` turns it on |
624
+ | `AGENTS_DECK_SKILL_HOOK` | Set to `0` to leave out the `UserPromptExpansion` hook, which lets Skill activity count the slash commands you type. On by default, and added only where every Claude Code found is 2.1.116 or newer; any value but `1` turns it off |
613
625
  | `CLAUDE_SWAP_BACKUP` | Override the claude-swap store root the Accounts panel reads |
614
626
  | `CCDECK_HOME` | Put everything the deck writes — its state and its log — under this directory instead of the platform default. Wins over every rule below it |
615
627
  | `CLAUDE_CONFIG_DIR` | Override `~/.claude` — the hook entry, the event log and everything else the deck writes move with it |
@@ -727,3 +727,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
727
727
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
728
728
  SOFTWARE.
729
729
  ```
730
+
731
+ ## Jira mark — Simple Icons
732
+
733
+ The monochrome Jira glyph on the left stripe and in Settings › Integrations
734
+ (`JiraGlyph` in `src/web/components/rail-glyphs.tsx`, #2178) is the path data
735
+ of the Jira icon from Simple Icons, https://github.com/simple-icons/simple-icons,
736
+ drawn in the stripe's own ink and never in Atlassian's colours. Simple Icons
737
+ releases its icon data under CC0 1.0 Universal:
738
+ https://creativecommons.org/publicdomain/zero/1.0/.
739
+
740
+ Jira and Atlassian are trademarks of Atlassian Pty Ltd. ccdeck is not
741
+ affiliated with or endorsed by Atlassian; the mark is used only to name the
742
+ service a connection reaches.
743
+
744
+ ## GitHub and GitLab marks — Simple Icons
745
+
746
+ The monochrome GitHub and GitLab glyphs on the left stripe and in Settings ›
747
+ Integrations (`GithubGlyph` and `GitlabGlyph` in
748
+ `src/web/components/rail-glyphs.tsx`, #2179) are the path data of the GitHub
749
+ and GitLab icons from Simple Icons, https://github.com/simple-icons/simple-icons,
750
+ drawn in the stripe's own ink and never in either company's colours. Simple
751
+ Icons releases its icon data under CC0 1.0 Universal:
752
+ https://creativecommons.org/publicdomain/zero/1.0/.
753
+
754
+ GitHub is a trademark of GitHub, Inc., and GitLab of GitLab Inc. ccdeck is not
755
+ affiliated with or endorsed by either; the marks are used only to name the
756
+ service a connection reaches.
@@ -216,9 +216,10 @@ export async function reportStartup(jobs, { workspace, wantClaude, wantCodex, CO
216
216
  process.exit(1);
217
217
  } else {
218
218
  write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
219
- // Skill activity's typed-command entry, said only when it was asked for
220
- // (AGENTS_DECK_SKILL_HOOK=1): installed, or why not. Not asked for, the
221
- // boot prints what it always printed. See skill-hook.mjs.
219
+ // Skill activity's typed-command entry, said only when the gate or the file
220
+ // kept it out, since then no typed command is counted. Installed, or turned
221
+ // off with AGENTS_DECK_SKILL_HOOK=0, the boot prints nothing more. See
222
+ // skill-hook.mjs.
222
223
  const skill = hooks.v.skillHook;
223
224
  if (skill && skill.state !== "off") {
224
225
  const { skillHookStatus } = await import(pathToFileURL(join(PKG_ROOT, "src/server/skill-hook.mjs")).href);
@@ -0,0 +1 @@
1
+ import{j as e,b as r,W as J,X as Q,Y,Z as X,_ as Z,$ as z,a0 as V,i as ee,s as se,n as te,o as ne,a1 as ae,a2 as ie,R as re,d as oe}from"./app-DhGvSH7o.js";function le({snap:s}){return e.jsxs("section",{className:"bw-feed",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Live activity",e.jsxs("span",{className:"bw-feed-count",children:[s.log.length," ",s.log.length===1?"event":"events"]})]}),e.jsx("p",{className:"bw-since bw-feed-note",children:"Pages you opened yourself — counted to run the rule, never written down. Addresses appear under Findings, and only for pages a program opened."}),e.jsx("div",{className:"bw-log","aria-live":"polite","aria-relevant":"additions",children:s.log.length===0?e.jsx("p",{className:"bw-note",children:"The deck writes a line here each time it looks at a profile."}):s.log.map(a=>e.jsxs("div",{className:`bw-log-line ${a.level}${a.parts?"":" sys"}`,children:[e.jsx("span",{className:"bw-log-time",children:new Date(a.atMs).toLocaleTimeString("en-GB",{hour12:!1})}),a.parts?e.jsxs(e.Fragment,{children:[e.jsx("span",{className:"bw-log-browser",children:a.parts.browser}),e.jsx("span",{className:"bw-log-profile",title:a.parts.profile,children:a.parts.profile}),e.jsxs("span",{className:"bw-log-value",children:[a.parts.value,a.parts.flagged>0&&e.jsxs("span",{className:"bw-log-flag",children:[" · ",a.parts.flagged," flagged"]})]})]}):e.jsx("span",{className:"bw-log-sys",children:a.text})]},`${a.atMs}-${a.level}-${a.text}`))})]})}const E=s=>`${s.host}-${s.startMs}`;function ce(s){const a=h=>new Date(h).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1});return s.count===1||s.endMs-s.startMs<6e4?a(s.startMs):`${a(s.startMs)} → ${a(s.endMs)}`}function de(s){return new Date(s).toLocaleDateString(void 0,{weekday:"short",day:"numeric",month:"short"})}function K(s){const a=Math.round((s.endMs-s.startMs)/6e4);return a<1?"":`${a} min`}function he({snap:s,dismiss:a,refreshRef:h}){const[c,d]=r.useState(null),u=r.useRef(new Map),t=r.useRef(null);r.useEffect(()=>{var i,w;const o=t.current;!o||s.episodes.some(b=>E(b)===o.id)||(t.current=null,J(((i=document.activeElement)==null?void 0:i.tagName)??null)&&((w=(o.next?u.current.get(o.next):void 0)??h.current)==null||w.focus()))},[s]);const n=o=>{t.current={id:E(o),next:Q(s.episodes.map(E),E(o))},a(o)},l=r.useMemo(()=>{const o=[];for(const i of s.episodes){const w=de(i.startMs),b=o[o.length-1];b&&b.label===w?b.episodes.push(i):o.push({label:w,episodes:[i]})}return o},[s]);return e.jsxs("section",{className:"bw-findings",children:[e.jsxs("h4",{className:"bw-sec-head bw-head-row",children:["Findings",s.episodes.length>0&&e.jsxs("span",{className:"bw-feed-count",children:[s.episodes.length," ",s.episodes.length===1?"episode":"episodes"]})]}),e.jsx("div",{className:"bw-eps",children:l.length===0?e.jsxs("div",{className:"bw-empty",children:[e.jsx("p",{className:"bw-empty-head",children:"Nothing found yet"}),e.jsxs("p",{className:"bw-empty-note",children:["An episode lands here when a program opens pages in a browser nobody has touched for"," ",s.settings.quietMinutes," ",s.settings.quietMinutes===1?"minute":"minutes",". On most machines that is rare, so an empty list is the ordinary result rather than a sign something is wrong."]}),!s.settings.enabled&&e.jsx("p",{className:"bw-empty-note",children:"Watching is off, so this shows only what this deck has seen since it started. Anything an earlier run recorded comes back when you switch it on."})]}):l.map(o=>e.jsxs("div",{className:"bw-day",children:[e.jsx("h4",{className:"bw-sec-head",children:o.label}),o.episodes.map(i=>{const w=E(i),b=c===w;return e.jsxs("div",{className:`bw-ep${b?" open":""}`,children:[e.jsxs("div",{className:"bw-ep-row",children:[e.jsxs("button",{className:"bw-ep-head",ref:p=>{p?u.current.set(w,p):u.current.delete(w)},onClick:()=>d(b?null:w),"aria-expanded":b,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:b?"▾":"▸"}),e.jsx("span",{className:"bw-ep-host",children:i.host}),e.jsxs("span",{className:"bw-ep-meta",children:[ce(i),K(i)&&e.jsxs(e.Fragment,{children:[" · ",K(i)]}),i.provisional&&e.jsxs(e.Fragment,{children:[" · ",e.jsx("span",{title:"Its quiet window has not closed yet. If somebody used the browser in the minutes after it, it is withdrawn; until then it is not written down and no reaction runs.",children:"provisional"})]})]}),e.jsxs("span",{className:"bw-ep-count",children:[i.count," ",i.count===1?"page":"pages"]})]}),e.jsx("button",{className:"glyph-btn bw-ep-x",onClick:()=>n(i),"aria-label":`Dismiss ${i.host}`,title:"Dismiss — it leaves this list for good, and stays in the log file",children:"×"})]}),b&&e.jsx("ul",{className:"bw-urls",children:i.urls.map((p,y)=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-url-time",children:new Date(p.timeMs).toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",hour12:!1})}),e.jsx("span",{className:"bw-url",children:p.url})]},`${p.url}-${p.timeMs}-${y}`))})]},w)})]},o.label))})]})}function ue(s){const a=/^#?([0-9a-f]{6})$/i.exec((s??"").trim());if(!a)return!1;const h=parseInt(a[1],16),c=[h>>16&255,h>>8&255,h&255].map(d=>{const u=d/255;return u<=.03928?u/12.92:Math.pow((u+.055)/1.055,2.4)});return .2126*c[0]+.7152*c[1]+.0722*c[2]>.5}const U=1e4,we=12e3;function me({browsers:s,findings:a,watching:h,palette:c}){const d=r.useRef(null),u=r.useRef(null);return u.current={browsers:s,findings:a,watching:h},r.useEffect(()=>{var b;const t=d.current;if(!t)return;const n=t.getContext("2d");if(!n)return;const l=((b=window.matchMedia)==null?void 0:b.call(window,"(prefers-reduced-motion: reduce)").matches)??!1;let o=0,i=!1;const w=()=>{var q;if(i)return;const p=u.current;if(!p)return;const{browsers:y,findings:j,watching:P}=p,S=c["--accent"]||"#7dd3fc",g=c["--line"]||"#1f2229",v=c["--text-dim"]||"#6e727c",$=ue(c["--bg"]),N=$?42:26,k=$?.85:.65,A=Date.now(),R=Math.min(window.devicePixelRatio||1,2),C=t.clientWidth,W=t.clientHeight;(t.width!==C*R||t.height!==W*R)&&(t.width=C*R,t.height=W*R),n.setTransform(R,0,0,R,0,0),n.clearRect(0,0,C,W);const m=C/2,x=W/2,M=Math.min(C,W)/2-6;n.strokeStyle=g,n.lineWidth=1,n.globalAlpha=$?1:.9;for(const f of[.4,.7,1])n.beginPath(),n.arc(m,x,M*f,0,Math.PI*2),n.stroke();n.beginPath(),n.moveTo(m-M,x),n.lineTo(m+M,x),n.moveTo(m,x-M),n.lineTo(m,x+M),n.stroke(),n.globalAlpha=1;const I=(P&&!l?A%U/U:0)*Math.PI*2-Math.PI/2;if(P){const f=(q=n.createConicGradient)==null?void 0:q.call(n,I-.85,m,x);f&&(f.addColorStop(0,"transparent"),f.addColorStop(.85/(Math.PI*2),`color-mix(in srgb, ${S} ${N}%, transparent)`),f.addColorStop(1,"transparent"),n.fillStyle=f,n.beginPath(),n.moveTo(m,x),n.arc(m,x,M,I-.85,I),n.closePath(),n.fill()),n.strokeStyle=S,n.globalAlpha=k,n.beginPath(),n.moveTo(m,x),n.lineTo(m+Math.cos(I)*M,x+Math.sin(I)*M),n.stroke(),n.globalAlpha=1}y.forEach((f,T)=>{const B=T/Math.max(y.length,1)*Math.PI*2-Math.PI/2,D=f.lastReadMs===null?1:Math.min(1,(A-f.lastReadMs)/12e4),O=M*(.42+D*.5),F=m+Math.cos(B)*O,L=x+Math.sin(B)*O,_=Math.abs((I-B+Math.PI*3)%(Math.PI*2)-Math.PI),G=P?Math.max(0,1-_/.55):0;n.beginPath(),n.arc(F,L,3+G*1.6,0,Math.PI*2),n.fillStyle=f.running?S:v,n.globalAlpha=f.running?.55+G*.45:.35,n.fill(),n.globalAlpha=1});for(const f of j){const T=(A-f.atMs)/we;if(T<0||T>1)continue;const D=Math.max(0,y.findIndex(L=>L.key===f.browser))/Math.max(y.length,1)*Math.PI*2-Math.PI/2,O=m+Math.cos(D)*M*.6,F=x+Math.sin(D)*M*.6;n.beginPath(),n.arc(O,F,4+T*22,0,Math.PI*2),n.strokeStyle=S,n.globalAlpha=(1-T)*.8,n.lineWidth=1.5,n.stroke(),n.globalAlpha=1}o=requestAnimationFrame(w)};return w(),()=>{i=!0,cancelAnimationFrame(o)}},[c]),e.jsx("canvas",{ref:d,className:"bw-radar",role:"img","aria-label":h?`Watching ${s.length} browser${s.length===1?"":"s"}`:"Not watching"})}function be({snap:s,watching:a,palette:h}){const[c,d]=r.useState(!1),[u,t]=r.useState(()=>Date.now());r.useEffect(()=>{const l=setInterval(()=>t(Date.now()),1e3);return()=>clearInterval(l)},[]);const n=Y(s.coverage.lastHumanMs,s.coverage.quietMs,u);return e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Activity overview",e.jsx("button",{className:"bw-help",onClick:()=>d(l=>!l),"aria-expanded":c,"aria-label":"What the sweep and the dots mean",children:"?"})]}),e.jsxs("div",{className:"bw-glance",children:[e.jsx(me,{browsers:a.map(l=>{var o;return{key:l.key,name:l.name,running:l.running,lastReadMs:((o=s.profiles.find(i=>i.browser===l.key))==null?void 0:o.lastWrittenMs)??null}}),findings:s.episodes.slice(0,6).map(l=>({browser:l.browser??null,atMs:l.endMs})),watching:s.settings.enabled,palette:h}),e.jsx("ul",{className:"bw-counts",children:X(a,s.profiles).map(l=>e.jsxs("li",{children:[e.jsx("span",{className:"bw-count-n",children:l.visits.toLocaleString("en-US")}),e.jsx("span",{className:"bw-count-of",children:l.name})]},l.key))})]}),e.jsx("p",{className:"bw-since",children:"Pages a program opened, since this deck started"}),s.coverage.checkedMs>0&&e.jsxs("p",{className:"bw-since",children:["Last checked ",Z(s.coverage.checkedMs,u),s.coverage.checks>0&&e.jsxs(e.Fragment,{children:[" · ",s.coverage.checks.toLocaleString("en-US")," ",s.coverage.checks===1?"check":"checks"]})]}),n!==null&&e.jsxs("p",{className:"bw-gate",children:["You are browsing — a program page would count in ",z(n)]}),c&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Sweep"}),e.jsx("dd",{children:"one poll of every watched profile"}),e.jsx("dt",{children:"Dot"}),e.jsx("dd",{children:"a browser, further out the longer since it last wrote history"}),e.jsx("dt",{children:"Ring"}),e.jsx("dd",{children:"a finding, leaving the browser it came from"})]})]})}function pe({watching:s,rest:a}){const[h,c]=r.useState(!1),[d,u]=r.useState(!1);return e.jsxs("section",{className:"bw-sec",children:[e.jsxs("h4",{className:"bw-sec-head",children:["Watched profiles",e.jsx("button",{className:"bw-help",onClick:()=>c(t=>!t),"aria-expanded":h,"aria-label":"What running, idle and connected to Anthropic mean",children:"?"})]}),h&&e.jsxs("dl",{className:"bw-key",children:[e.jsx("dt",{children:"Running"}),e.jsx("dd",{children:"the browser has a process on this machine right now"}),e.jsx("dt",{children:"Connected"}),e.jsx("dd",{children:"an open connection to an address Anthropic's relay uses — which it shares with claude.ai, so an open tab looks the same as an agent channel"})]}),e.jsx("ul",{className:"bw-profiles",children:s.map(t=>e.jsxs("li",{children:[e.jsx("span",{className:`bw-prof-dot${t.running?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-prof-name",children:t.name}),e.jsx("span",{className:"bw-prof-state",children:t.running?"running":"idle"}),t.running&&t.relay.state==="live"&&e.jsx("span",{className:"bw-relay",children:"connected to Anthropic"})]},t.key))}),a.length>0&&e.jsxs(e.Fragment,{children:[e.jsxs("button",{className:"bw-why bw-rest-head",onClick:()=>u(t=>!t),"aria-expanded":d,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:d?"▾":"▸"})," ",a.length," not watched"]}),d&&e.jsxs("p",{className:"bw-rest",children:[a.map(t=>t.name).join(", ")," — not installed, or installed and never opened, so there is no history to read."]})]})]})}function ge({snap:s,quiet:a,setQuiet:h,save:c,access:d,setAccess:u}){return e.jsxs("div",{className:"bw-settings",children:[e.jsxs("label",{title:"A program opening a page counts as a finding only if nobody had touched the browser for this long. Shorter catches more and reports more of your own work; longer is quieter.",children:[e.jsx("span",{children:"Nobody browsing for"}),e.jsxs("select",{value:a??s.settings.quietMinutes,onChange:t=>{h(Number(t.target.value)),c({quietMinutes:Number(t.target.value)})},children:[e.jsx("option",{value:1,children:"1 min"}),e.jsx("option",{value:5,children:"5 min"}),e.jsx("option",{value:15,children:"15 min"}),e.jsx("option",{value:30,children:"30 min"}),e.jsx("option",{value:60,children:"60 min"})]})]}),e.jsxs("label",{children:[e.jsx("span",{children:"When it finds one"}),e.jsx("select",{value:s.settings.reaction,onChange:t=>void c({reaction:t.target.value}),disabled:!s.settings.enabled,title:s.settings.enabled?"What to do besides writing it down.":"Turn watching on to arm a reaction.",children:(s.reactions??["notify"]).map(t=>e.jsx("option",{value:t,children:t==="notify"?"notify me":t==="close-tab"?"close the tab":"quit the browser"},t))})]}),e.jsxs("div",{className:"bw-settings-note",children:[e.jsxs("p",{children:["Chrome marks a navigation that came from an extension or a command rather than from a click. These are the ones that happened while nobody had touched the browser for the time above. ",e.jsx("strong",{children:"Usually that is your own agent doing what you asked."})]}),e.jsxs("button",{className:"bw-why",onClick:()=>u(t=>!t),"aria-expanded":d,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:d?"▾":"▸"})," What Browser Watch can access"]}),d&&e.jsxs("dl",{className:"bw-access",children:[e.jsx("dt",{children:"Reads"}),e.jsxs("dd",{children:["A copy of each browser's own history database — the live file is locked while the browser holds it. Only rows newer than the moment this deck started:"," ",new Date(s.coverage.startedMs).toLocaleString(),"."]}),e.jsx("dt",{children:"Keeps"}),e.jsxs("dd",{children:["Only while the switch is on, and only the episodes it flagged — never your ordinary browsing. In ",e.jsx("code",{className:"bw-path",children:s.coverage.logPath}),typeof s.coverage.logBytes=="number"?` (${V(s.coverage.logBytes)})`:"",", with every address written in full so you can check it yourself."]}),e.jsx("dt",{children:"Sends"}),e.jsx("dd",{children:"Nothing. No part of this reads or writes over the network; the deck serves on 127.0.0.1 and this panel talks only to it."}),e.jsx("dt",{children:"Never reads"}),e.jsx("dd",{children:"Anything from before this deck started, cookies, saved passwords, page contents, or any browser profile with no history file."})]})]})]})}function H(s,a){return a?{kind:"saving",word:"Saving"}:s.settings.enabled?{kind:"on",word:"Watching"}:{kind:"off",word:"Paused"}}function fe({snap:s,saving:a,save:h,why:c,setWhy:d}){return e.jsxs("footer",{className:"bw-status",children:[e.jsx("span",{className:`bw-mode-dot${s.settings.enabled?" on":""}`,"aria-hidden":!0}),e.jsx("span",{className:"bw-mode-word",children:H(s,a).word},H(s,a).kind),e.jsx("span",{className:"bw-status-text",children:s.coverage.archived===0?s.settings.enabled?"No episodes recorded yet":"No episodes on disk":`${s.coverage.archived} ${s.coverage.archived===1?"episode":"episodes"} on disk`}),e.jsx("button",{className:`glyph-btn bw-gear${c?" on":""}`,onClick:()=>d(u=>!u),"aria-expanded":c,"aria-label":"Settings",title:"Settings",children:e.jsxs("svg",{width:"13",height:"13",viewBox:"0 0 14 14",fill:"none",stroke:"currentColor",strokeWidth:"1.3","aria-hidden":!0,children:[e.jsx("circle",{cx:"7",cy:"7",r:"2.1"}),e.jsx("path",{d:"M7 1.2v1.6M7 11.2v1.6M1.2 7h1.6M11.2 7h1.6M2.9 2.9l1.1 1.1M10 10l1.1 1.1M11.1 2.9L10 4M4 10l-1.1 1.1"})]})}),e.jsxs("label",{className:"bw-switch",htmlFor:"bw-enabled",children:[e.jsx("span",{className:"bw-switch-label",id:"bw-enabled-label",children:"Watch browser activity"}),e.jsx("button",{id:"bw-enabled",type:"button",role:"switch","aria-checked":s.settings.enabled,"aria-labelledby":"bw-enabled-label",className:"switch",onClick:()=>void h({enabled:!s.settings.enabled}),title:s.settings.enabled?"On — every episode it finds is written down, so the list outlives the browsing history being cleared":"Off — showing only what this deck has seen since it started. Anything an earlier run archived is hidden until you switch back on, and nothing new is kept",children:e.jsx("span",{className:"switch-knob"})})]})]})}function xe({relay:s}){const[a,h]=r.useState(!1),[c,d]=r.useState(!1),u=s.killswitch.blocked?"unblock":"block",t=s.command[u],n=r.useCallback(async()=>{await ee(t.command)&&(d(!0),window.setTimeout(()=>d(!1),1600))},[t.command]),l=s.profiles.filter(i=>{var w;return(w=i.report)==null?void 0:w.present}),o=s.profiles.filter(i=>i.report===null);return e.jsxs("section",{className:"bw-sec",children:[e.jsx("h4",{className:"bw-sec-head",children:"Remote control"}),e.jsx("p",{className:`bw-rc-verdict ${s.verdict}`,role:"status",children:s.verdict==="exposed"?"Claude in Chrome is live here, so any Claude Code session signed in to your Anthropic account can drive this browser — from any machine, with no prompt on this one.":s.verdict==="protected"?`The relay is black-holed in ${s.hostsPath}, so no new connection to it can be made from this machine.`:"Claude in Chrome is not installed and enabled in any profile here, so there is nothing for the relay to drive."}),s.killswitch.foreign.length>0&&e.jsxs("p",{className:"bw-rc-foreign",children:["Another entry for this host is already in ",s.hostsPath,", and ccdeck will not touch it:",s.killswitch.foreign.map(i=>e.jsx("code",{className:"bw-path",children:i},i))]}),e.jsxs("button",{className:"bw-why",onClick:()=>h(i=>!i),"aria-expanded":a,children:[e.jsx("span",{className:"bw-chev","aria-hidden":!0,children:a?"▾":"▸"})," ",s.verdict==="protected"?"How to undo the block":"What it can reach, and how to stop it"]}),a&&e.jsxs("div",{className:"bw-rc-body",children:[l.length>0&&e.jsx("dl",{className:"bw-key",children:l.map(i=>e.jsxs(r.Fragment,{children:[e.jsxs("dt",{children:[i.name,i.profile==="Default"?"":` · ${i.profile}`]}),e.jsxs("dd",{children:[i.report.enabled?"enabled":"installed but switched off",i.report.allUrls?", can act on every site":", limited to the sites it was granted",i.report.sensitiveApis.length>0&&e.jsxs(e.Fragment,{children:[" — ",i.report.sensitiveApis.join(", ")]})]})]},`${i.browser}/${i.profile}`))}),o.length>0&&e.jsxs("p",{className:"bw-rc-note",children:[o.map(i=>i.name).join(", ")," — its settings file could not be read, so nothing is claimed about it either way."]}),!s.hostsRead&&e.jsxs("p",{className:"bw-rc-note",children:[s.hostsPath," could not be read, so whether the relay is already blocked is unknown."]}),l.some(i=>i.report.allUrls)&&e.jsxs("p",{className:"bw-rc-note",children:["Narrowing the sites in ",e.jsx("code",{className:"bw-path",children:"chrome://extensions"})," does not change this: the grant is held at the browser level and the per-site list is enforced inside the extension, by the extension."]}),e.jsxs("div",{className:"bw-rc-cmd",children:[e.jsx("code",{children:t.command}),e.jsx("button",{className:"btn bw-rc-copy",onClick:n,children:c?"Copied":"Copy"})]}),e.jsx("p",{className:"bw-rc-note",children:t.note})]})]})}function je(s){const[a,h]=r.useState(null),[c,d]=r.useState(null),[u,t]=r.useState(null),[n,l]=r.useState(!1),o=r.useRef(!1),[i,w]=r.useState(null),[b,p]=r.useState(!1),y=r.useRef(!1),j=r.useCallback(async g=>{var v,$;if(!(g&&!se(o.current))){g&&(o.current=!0,l(!0));try{const N=await fetch(`/api/browser-watch${g?"?refresh=1":""}`);if(!N.ok)throw new Error(`the deck answered ${N.status}`);const k=await N.json();h(k),y.current||w(((v=k==null?void 0:k.settings)==null?void 0:v.quietMinutes)??15),s((($=k==null?void 0:k.settings)==null?void 0:$.enabled)===!0),d(null)}catch(N){d(N instanceof Error?N.message:String(N))}finally{g&&(o.current=!1,l(!1))}}},[s]);r.useEffect(()=>{j(!1)},[j]),r.useEffect(()=>{const g=setInterval(()=>{j(!1)},1e4);return()=>clearInterval(g)},[j]);const P=r.useCallback(async g=>{try{await fetch("/api/browser-watch/dismiss",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({host:g.host,startMs:g.startMs})})}finally{await j(!1)}},[j]),S=r.useCallback(async g=>{y.current=!0,p(!0);try{const v=await fetch("/api/browser-watch",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(g)});if(!v.ok)throw new Error(`the deck answered ${v.status}`);await j(!1),t(null)}catch(v){t(v instanceof Error?v.message:String(v))}finally{y.current=!1,p(!1)}},[j]);return{snap:a,error:c,writeError:u,setWriteError:t,busy:n,quiet:i,setQuiet:w,saving:b,load:j,dismiss:P,save:S}}function Ne({onClose:s,onSeen:a,onWatching:h,palette:c}){const d=te(s),u=ne(s),{snap:t,error:n,writeError:l,setWriteError:o,busy:i,quiet:w,setQuiet:b,saving:p,load:y,dismiss:j,save:P}=je(h),[S,g]=r.useState(!1),[v,$]=r.useState(!1),N=r.useRef(null),k=r.useRef(a);k.current=a,r.useEffect(()=>()=>k.current(Date.now()),[]);const A=ae(t==null?void 0:t.browsers),R=((t==null?void 0:t.browsers)??[]).filter(m=>!(m.installed&&m.profiles>0)),C=t?ie(t):null,W=A.reduce((m,x)=>m+x.profiles,0);return e.jsx("div",{className:"modal-backdrop",...u,role:"presentation",children:e.jsxs("div",{ref:d,className:"modal bw-modal",onClick:m=>m.stopPropagation(),role:"dialog","aria-modal":"true","aria-labelledby":"bw-title",children:[e.jsxs("header",{className:"modal-head",children:[e.jsxs("div",{className:"modal-title",children:[e.jsx("span",{className:"modal-tool-name",id:"bw-title",children:"Browser watch"}),e.jsx("span",{className:"modal-tool-id",children:t?`${W} ${W===1?"profile":"profiles"} · local only`:"local only"})]}),e.jsxs("div",{className:"modal-actions",children:[e.jsx("button",{ref:N,className:"glyph-btn",onClick:()=>void y(!0),...oe(i),"aria-label":"Re-read every profile now",title:"Re-read every profile now",children:i?"…":e.jsx(re,{})}),e.jsx("button",{className:"glyph-btn",onClick:s,"aria-label":"Close (Esc)",title:"Close (Esc)",children:"×"})]})]}),e.jsxs("div",{className:"modal-body bw-body",children:[!t&&!n&&e.jsxs("div",{className:"bw-loading",children:[e.jsx("span",{className:"bw-loading-bar","aria-hidden":!0}),e.jsx("p",{children:"Reading each browser's history. The first look copies the file, which takes a moment."})]}),n&&e.jsx("div",{className:"bw-state",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not read"}),e.jsx("span",{className:"bw-row-detail",children:n})]})}),t&&C&&e.jsx("p",{className:`bw-trouble ${C.kind}`,role:"status",children:C.text}),t&&e.jsxs("div",{className:"bw-work",children:[e.jsxs("aside",{className:"bw-side",children:[e.jsx(be,{snap:t,watching:A,palette:c}),e.jsx(pe,{watching:A,rest:R}),t.relay&&e.jsx(xe,{relay:t.relay})]}),e.jsxs("div",{className:"bw-main",children:[e.jsx(he,{snap:t,dismiss:j,refreshRef:N}),e.jsx(le,{snap:t})]})]}),t&&S&&e.jsx(ge,{snap:t,quiet:w,setQuiet:b,save:P,access:v,setAccess:$}),l&&e.jsx("div",{className:"bw-state bw-write-err",role:"alert",children:e.jsxs("div",{className:"bw-row err",children:[e.jsx("span",{className:"bw-dot","aria-hidden":!0}),e.jsx("span",{className:"bw-row-label",children:"Could not save that setting"}),e.jsxs("span",{className:"bw-row-detail",children:[l," — the switches below show what is stored, not what you pressed."]}),e.jsx("button",{className:"glyph-btn bw-write-err-x",onClick:()=>o(null),"aria-label":"Dismiss",title:"Dismiss",children:"×"})]})}),t&&e.jsx(fe,{snap:t,saving:p,save:P,why:S,setWhy:g})]})]})})}export{Ne as default};