ccdeck 3.41.1 → 3.42.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.
@@ -53,8 +53,8 @@
53
53
  <script>
54
54
  (function () {
55
55
  // Generated from JSON alongside the CSS and Settings catalog.
56
- var themes = /* theme-ids:start */ ["light","dark","rider-black","vscode-black","omarchy","matrix","black-contrast","white-contrast","catppuccin-mocha"] /* theme-ids:end */;
57
- var lightThemes = /* theme-light-ids:start */ ["light","white-contrast"] /* theme-light-ids:end */;
56
+ var themes = /* theme-ids:start */ ["light","dark","rider-black","vscode-black","omarchy","matrix","black-contrast","white-contrast","catppuccin-mocha","gruvbox-dark","gruvbox-light","gruvbox-material"] /* theme-ids:end */;
57
+ var lightThemes = /* theme-light-ids:start */ ["light","white-contrast","gruvbox-light"] /* theme-light-ids:end */;
58
58
  var stored = null;
59
59
  // `window.localStorage` is a GETTER that throws where site data is
60
60
  // blocked, so the property read is inside the try, not just getItem.
@@ -66,8 +66,19 @@
66
66
  document.documentElement.setAttribute("data-color-scheme", lightThemes.indexOf(theme) >= 0 ? "light" : "dark");
67
67
  })();
68
68
  </script>
69
- <script type="module" crossorigin src="/assets/index-2Z0Tr6cq.js"></script>
70
- <link rel="stylesheet" crossorigin href="/assets/index-BgSnAnP6.css">
69
+ <script>
70
+ (function () {
71
+ var size = null, density = null;
72
+ try {
73
+ size = window.localStorage.getItem("agent-dag.text-size");
74
+ density = window.localStorage.getItem("agent-dag.density");
75
+ } catch (e) {}
76
+ document.documentElement.setAttribute("data-text-size", size === "smallest" || size === "smaller" || size === "larger" || size === "largest" ? size : "default");
77
+ document.documentElement.setAttribute("data-density", density === "tightest" || density === "tighter" || density === "compact" || density === "comfortable" ? density : "default");
78
+ })();
79
+ </script>
80
+ <script type="module" crossorigin src="/assets/index-DzDnLqDX.js"></script>
81
+ <link rel="stylesheet" crossorigin href="/assets/index-BPLXy9SO.css">
71
82
  </head>
72
83
  <body>
73
84
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.41.1",
3
+ "version": "3.42.0",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -75,7 +75,26 @@
75
75
  "3.38.2",
76
76
  "3.38.6",
77
77
  "3.39.4",
78
- "3.40.1", "3.40.2"
78
+ "3.40.1",
79
+ "3.41.3"
80
+ ],
81
+ "3.42.0": [
82
+ {
83
+ "title": "🔠 Text size and density, in Settings",
84
+ "body": "Five text sizes and five spacing levels, under Settings › Text & density."
85
+ },
86
+ {
87
+ "title": "🛠️ Tool activity: which tools your agents use, how long they take and what fails",
88
+ "body": "A new page on the stripe on the right. Its history is kept on this device only."
89
+ },
90
+ {
91
+ "title": "🎨 Gruvbox Dark, Light and Material themes",
92
+ "body": "Pick one in Settings › General. Switching themes now fades instead of snapping."
93
+ },
94
+ {
95
+ "title": "✨ A session-list drawer on phones, whole dates in usage history, the waiting session in the minimap",
96
+ "body": "Smaller fixes for the phone layout, the usage history chart and the canvas minimap."
97
+ }
79
98
  ],
80
99
  "3.41.1": [
81
100
  {
@@ -121,6 +140,12 @@
121
140
  "body": "Change individual telemetry flags and use a simpler Monitor view."
122
141
  }
123
142
  ],
143
+ "3.40.2": [
144
+ {
145
+ "title": "📻 Music is in Radio now, not in Settings",
146
+ "body": "Settings no longer has a Music section. Claude FM, Lofi Girl and the streams you added are under Workspace streams in Radio, on the right sidebar, and Radio’s player has the volume and mute. Your station, volume and streams are kept. Adding, renaming or removing a stream link is no longer offered."
147
+ }
148
+ ],
124
149
  "3.40.0": [
125
150
  {
126
151
  "title": "📻 Explore radio around the world",
@@ -128,7 +153,7 @@
128
153
  },
129
154
  {
130
155
  "title": "🦊 Choose your canvas companion",
131
- "body": "Choose Deck companion, Fox or Penguin in Settings › Appearance. Your companion shares the existing canvas movements and music controls, and your choice stays saved."
156
+ "body": "Choose Deck companion, Fox or Penguin in Settings › General. Your companion shares the existing canvas movements and music controls, and your choice stays saved."
132
157
  },
133
158
  {
134
159
  "title": "📊 See what each session used",
@@ -147,12 +172,16 @@
147
172
  {
148
173
  "title": "Clearer account usage and Telemetry Radar",
149
174
  "body": "Account usage shows reset times together with capacity. Telemetry Radar has a clearer monitoring workspace and keeps history for up to 24 hours. Radar remains Beta."
175
+ },
176
+ {
177
+ "title": "🔎 Telemetry Radar on Linux and Windows, with no Terminal step",
178
+ "body": "Radar now works on macOS, Linux and Windows. Press Start monitoring and ccdeck runs the capture itself, with no command to paste into Terminal, and keeps listening until you stop it. Capture needs a one-time setup for your user: Wireshark’s ChmodBPF helper on macOS, tcpdump with capture permission on Linux, or Wireshark with Npcap on Windows. Radar says what is missing, and once permission is granted it starts listening by itself."
150
179
  }
151
180
  ],
152
181
  "3.39.1": [
153
182
  {
154
183
  "title": "🎨 More themes, with clearer contrast",
155
- "body": "Choose Omarchy, Matrix, Black Contrast, White Contrast or Catppuccin Mocha in Settings › Appearance. Fresh installations start with Rider Black; your saved theme choice is preserved."
184
+ "body": "Choose Omarchy, Matrix, Black Contrast, White Contrast or Catppuccin Mocha in Settings › General. Fresh installations start with Rider Black; your saved theme choice is preserved."
156
185
  },
157
186
  {
158
187
  "title": "A little more room on the canvas",
@@ -162,7 +191,7 @@
162
191
  "3.39.0": [
163
192
  {
164
193
  "title": "🔎 Inspect Claude Code telemetry on macOS",
165
- "body": "Telemetry Radar shows Claude Code's configured telemetry and can inspect traffic at destinations you choose. When settings are absent, it can show observed connections separately; a connection alone does not prove telemetry or reveal message contents. Capture starts only after you run its generated command in Terminal, which uses macOS tcpdump with administrator permission. Captured telemetry can contain prompt or response content, and other apps sending to the same destination may appear too. Messages stay in ccdeck's memory for up to five minutes; no capture file is written."
194
+ "body": "Telemetry Radar shows Claude Code's configured telemetry and can inspect traffic at destinations you choose. When settings are absent, it can show observed connections separately; a connection alone does not prove telemetry or reveal message contents. Captured telemetry can contain prompt or response content, and other apps sending to the same destination may appear too. Captured messages stay in ccdeck's memory, and no capture file is written."
166
195
  },
167
196
  {
168
197
  "title": "🧭 Open panels from the edges",
@@ -174,13 +203,13 @@
174
203
  },
175
204
  {
176
205
  "title": "🎨 Two new dark themes",
177
- "body": "Choose Rider Black or VS Code Black in Settings › Appearance. Both add a darker canvas with flatter panels and distinct focus accents; your existing Light or Dark choice stays as it was."
206
+ "body": "Choose Rider Black or VS Code Black in Settings › General. Both add a darker canvas with flatter panels and distinct focus accents; your existing Light or Dark choice stays as it was."
178
207
  }
179
208
  ],
180
209
  "3.38.5": [
181
210
  {
182
211
  "title": "🔧 Settings are in one place now",
183
- "body": "The theme, the sounds, notifications, Claude FM and the minimap character are all in Settings, behind the gear at the right of the topbar, or Cmd+, (Ctrl+, on Windows and Linux). The speaker beside it keeps the quick things one click away: sounds on or off, and how loud each tone is. Nothing you had set has changed, it has only moved, and T still switches the theme. The auto-switch threshold also takes a custom value now: pick Custom… and type a whole percent from 50 to 99."
212
+ "body": "The theme, the sounds, notifications and the minimap character are all in Settings, behind the gear at the right of the topbar, or Cmd+, (Ctrl+, on Windows and Linux). V opens it at Sounds, with each tone's volume and sound, and M turns sounds off or on from anywhere. Nothing you had set has changed, it has only moved, and T still switches the theme. The auto-switch threshold also takes a custom value now: pick Custom… and type a whole percent from 50 to 99."
184
213
  },
185
214
  {
186
215
  "title": "Windows: no more temperature stuck at 28 °C",
@@ -190,7 +219,7 @@
190
219
  "3.38.4": [
191
220
  {
192
221
  "title": "🔔 A notification when Auto-switch changes your account",
193
- "body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work · personal@example.com reached 90%\". You can turn it off in the sound settings, where two more wait, off until you turn them on: one when a quota window of the Claude or Codex account you are on reaches 90% and again at 100%, and one when a window that had reached 90% is available again. Each is said once per window and a restart does not repeat it. With either quota notification on, the deck reads quota about every two minutes even while no page is open."
222
+ "body": "When Auto-switch moves Claude Code to another account, ccdeck now tells you once, with where it went and why, for example \"Switched to work · personal@example.com reached 90%\". You can turn it off in Settings › Notifications, where two more wait, off until you turn them on: one when a quota window of the Claude or Codex account you are on reaches 90% and again at 100%, and one when a window that had reached 90% is available again. Each is said once per window and a restart does not repeat it. With either quota notification on, the deck reads quota about every two minutes even while no page is open."
194
223
  }
195
224
  ],
196
225
  "3.38.3": [
@@ -228,7 +257,7 @@
228
257
  },
229
258
  {
230
259
  "title": "💬 Send feedback from inside the deck",
231
- "body": "Appearance settings has a \"Send feedback…\" button: pick what kind it is, write a title and a few lines, and add a way to reach you if you want an answer. It goes to the people who make ccdeck, who may open a public issue from it, never with your contact in it. If it cannot be sent, the form stays filled in and says why."
260
+ "body": "Feedback, beside Settings at the right of the topbar (under More on a phone), opens a short form: pick what kind it is, write a few lines, and add a way to reach you under Add details if you want an answer. It goes to the people who make ccdeck, who may open a public issue from it, never with your contact in it. If it cannot be sent, the form stays filled in and says why."
232
261
  }
233
262
  ],
234
263
  "3.33.0": [
@@ -382,7 +411,7 @@
382
411
  },
383
412
  {
384
413
  "title": "📅 Projects opens on Today",
385
- "body": "The Projects report per account starts with today's work, and an empty window offers the next wider one. The topbar also shows this month's usage, and the desktop app can restart into an update from its own window."
414
+ "body": "The Projects report per account starts with today's work, and an empty window offers the next wider one. The desktop app can also restart into an update from its own window."
386
415
  }
387
416
  ],
388
417
  "3.27.0": [
@@ -440,7 +469,7 @@
440
469
  },
441
470
  {
442
471
  "title": "🎨 See each theme before you pick it",
443
- "body": "The Appearance menu shows a small preview of the deck in Light and in Dark. Claude FM also keeps its volume when a notification chime plays, instead of dipping under it."
472
+ "body": "Settings › General shows a small preview of the deck in each theme. Claude FM also keeps its volume when a notification chime plays, instead of dipping under it."
444
473
  }
445
474
  ],
446
475
  "3.24.1": [
@@ -598,11 +627,11 @@
598
627
  },
599
628
  {
600
629
  "title": "⎋ Esc leaves the machine panel where it is",
601
- "body": "The × on **This machine** read `Close (Esc)`, so Escape put the panel away. Escape belongs to the canvas — it deselects and hands the keyboard back — and pressing it while you are reading your instruments should not close them.\n\nThe panel now closes the two ways it opens: its ×, and the meter in the topbar. The × says `Close`, and names no key it does not answer."
630
+ "body": "The × on **This machine** read `Close (Esc)`, so Escape put the panel away. Escape belongs to the canvas — it deselects and hands the keyboard back — and pressing it while you are reading your instruments should not close them.\n\nThe panel now closes the ways it opens: its ×, its button on the window's right edge, and S. The × says `Close`, and names no key it does not answer."
602
631
  },
603
632
  {
604
633
  "title": "🖥 The machine moved out of the topbar and into a button",
605
- "body": "The small CPU sparkline and memory bar in the topbar are gone. A chip glyph in the icon row beside `$` and the accounts silhouette opens the same **This machine** panel — cores, memory, swap, load, temperature, busiest processes — and the bar stops moving in the corner of your eye.\n\nIt also stops asking. The deck read your machine every three seconds for the life of every tab to feed that meter; it now reads only while the panel is open, and the history behind the readings is kept by the deck itself, so a panel opened an hour from now still has the hour behind it."
634
+ "body": "The small CPU sparkline and memory bar in the topbar are gone. A chip glyph, on the window's right edge now, opens the same **This machine** panel — cores, memory, swap, load, temperature, busiest processes — and the bar stops moving in the corner of your eye.\n\nIt also stops asking. The deck read your machine every three seconds for the life of every tab to feed that meter; it now reads only while the panel is open, and the history behind the readings is kept by the deck itself, so a panel opened an hour from now still has the hour behind it."
606
635
  },
607
636
  {
608
637
  "title": "🔎 The panel's eight process rows became one way in",
@@ -660,7 +689,7 @@
660
689
  "3.9.0": [
661
690
  {
662
691
  "title": "🎛️ Five switches that were not where the thing they control is",
663
- "body": "**The session list has a button again.** It lost one a while back and only the `L` key was left — and `L` is written down in one place, a shortcuts sheet behind a small `?` in the corner of the canvas. If you use a mouse there was no way to open the list at all. The `☰` is back at the left of the top bar.\n\n**The Notifications switch now tells you whether the browser will actually show one.** It said `on` while this browser had never been asked for permission, which is not the same thing. It now says so on that row and offers the ask right there. Before, the only place to grant it was a button that appears solely while a session is stuck waiting — so on a machine where that rarely happens you could not switch notifications on at all.\n\n**Started with `--workspace` or `--scope` and seeing nothing?** The empty screen used to tell everyone to \"run `claude` or `codex` in any folder\", which is the one thing that cannot work for you. It now names the folder the deck is actually watching.\n\n**Browser Watch says when a setting did not save.** Flipping the switch and having it fail showed a red line reading \"Could not read\" — about reading history, not about your press — and ten seconds later the panel wiped it and looked perfectly healthy with the setting unchanged. A failed save now says so, in its own words, and stays until you dismiss it. The \"nobody browsing for\" menu also follows the stored value again: change it in a second tab and this one never caught up.\n\n**The deck no longer restarts itself when nothing on screen offers to stop it.** Thirty seconds after the last agent went quiet, with a new version on disk, the server would exit, come back and reload the page. The `auto when idle` switch that controls that lives inside the update banner — so if you had closed the banner with its `×`, it happened with no visible setting anywhere. Closing the banner now also stands down the restart; clicking the version chip brings back both together."
692
+ "body": "**The session list has a button again.** It lost one a while back and only the `L` key was left — and `L` is written down in one place, a shortcuts sheet behind a small `?` in the corner of the canvas. If you use a mouse there was no way to open the list at all. Its button is on the window's left edge now.\n\n**The Notifications switch now tells you whether the browser will actually show one.** It said `on` while this browser had never been asked for permission, which is not the same thing. It now says so on that row and offers the ask right there. Before, the only place to grant it was a button that appears solely while a session is stuck waiting — so on a machine where that rarely happens you could not switch notifications on at all.\n\n**Started with `--workspace` or `--scope` and seeing nothing?** The empty screen used to tell everyone to \"run `claude` or `codex` in any folder\", which is the one thing that cannot work for you. It now names the folder the deck is actually watching.\n\n**Browser Watch says when a setting did not save.** Flipping the switch and having it fail showed a red line reading \"Could not read\" — about reading history, not about your press — and ten seconds later the panel wiped it and looked perfectly healthy with the setting unchanged. A failed save now says so, in its own words, and stays until you dismiss it. The \"nobody browsing for\" menu also follows the stored value again: change it in a second tab and this one never caught up.\n\n**The deck no longer restarts itself when nothing on screen offers to stop it.** Thirty seconds after the last agent went quiet, with a new version on disk, the server would exit, come back and reload the page. The `auto when idle` switch that controls that lives inside the update banner — so if you had closed the banner with its `×`, it happened with no visible setting anywhere. Closing the banner now also stands down the restart; clicking the version chip brings back both together."
664
693
  }
665
694
  ],
666
695
  "3.8.9": [
@@ -714,25 +743,25 @@
714
743
  "3.8.1": [
715
744
  {
716
745
  "title": "🔒 Security: update if the deck runs on a Mac",
717
- "body": "A local process able to reach the deck's port — no token, no browser, no tab — could get a string of its choosing compiled as AppleScript when the deck raised a desktop notification with no page open. On macOS that is code running as you.\n\nThe strings were already passed as arguments rather than pasted into the script, which is normally the whole defence. It is not enough for `osascript`: its option parser reads any argument starting with a dash as an option, so a name beginning with `-e` was taken as more script instead of as text. Every place the deck hands `osascript` or `notify-send` something from outside now carries an explicit end-of-options marker, so nothing from outside can be read as an instruction again.\n\nNothing to change on your side beyond updating. If you would rather keep the deck off the desktop entirely, the Notifications switch in the sound menu does that, and `AGENTS_DECK_NO_NOTIFY=1` does it at launch — but the fix is the thing to have."
746
+ "body": "A local process able to reach the deck's port — no token, no browser, no tab — could get a string of its choosing compiled as AppleScript when the deck raised a desktop notification with no page open. On macOS that is code running as you.\n\nThe strings were already passed as arguments rather than pasted into the script, which is normally the whole defence. It is not enough for `osascript`: its option parser reads any argument starting with a dash as an option, so a name beginning with `-e` was taken as more script instead of as text. Every place the deck hands `osascript` or `notify-send` something from outside now carries an explicit end-of-options marker, so nothing from outside can be read as an instruction again.\n\nNothing to change on your side beyond updating. If you would rather keep the deck off the desktop entirely, the Notifications switch in Settings › Notifications does that, and `AGENTS_DECK_NO_NOTIFY=1` does it at launch — but the fix is the thing to have."
718
747
  }
719
748
  ],
720
749
  "3.8.0": [
721
750
  {
722
751
  "title": "🔕 Notifications now have an off switch",
723
- "body": "3.7.0 gave you a \"notify me\" button and no way back. Turning them off meant digging into your browser's site settings — which no page can do for you — and that still left the other notifier, the one the deck raises when no page is open at all. Silencing that one meant quitting the deck and starting it again with `AGENTS_DECK_NO_NOTIFY=1` in front. The person who wanted quiet was the one doing the work.\n\nThere is a **Notifications** switch in the sound menu now, beside the one for sound — that menu is already \"how loudly does this deck interrupt me\". One switch governs both notifiers, it takes effect immediately without a restart, and it is remembered across restarts.\n\nIt is kept by the deck rather than by your browser, because the second notifier runs when no page exists and a setting living in a page could never reach it. `AGENTS_DECK_NO_NOTIFY=1` still wins over the switch — a machine told at launch to stay off the desktop stays off it — and when that is the case the menu says so instead of showing an \"on\" that will not happen."
752
+ "body": "3.7.0 gave you a \"notify me\" button and no way back. Turning them off meant digging into your browser's site settings — which no page can do for you — and that still left the other notifier, the one the deck raises when no page is open at all. Silencing that one meant quitting the deck and starting it again with `AGENTS_DECK_NO_NOTIFY=1` in front. The person who wanted quiet was the one doing the work.\n\nThere is a **Notifications** switch in Settings › Notifications now. One switch governs both notifiers, it takes effect immediately without a restart, and it is remembered across restarts.\n\nIt is kept by the deck rather than by your browser, because the second notifier runs when no page exists and a setting living in a page could never reach it. `AGENTS_DECK_NO_NOTIFY=1` still wins over the switch — a machine told at launch to stay off the desktop stays off it — and when that is the case Settings says so instead of showing an \"on\" that will not happen."
724
753
  }
725
754
  ],
726
755
  "3.7.1": [
727
756
  {
728
757
  "title": "🔔 The notifications now fire on the thing that actually stops your agent",
729
- "body": "3.7.0 could tell you a session was waiting — but only when Claude Code asked permission to run a tool. If you work with permissions bypassed, that never happens, and the whole feature was silent for you. Measured on one real log: 1683 events, every one of them in bypassPermissions, and a single permission prompt in the entire history.\n\nWhat fires constantly on that setup is the other kind: the agent asking you a question and stopping for the answer. The deck was throwing those away — no amber chip, no row in the sidebar, no notification, and no \"notify me\" button, because the button only appears while something is actually blocked.\n\nNow they count. The card carries the question itself rather than a generic \"Your turn\" — \"paycore needs your input: merge both branches to main, or just one?\" — and so does the notification, on the desktop, whether the tab is behind something or not open at all.\n\nAn idle prompt still stays quiet: a turn that ended is not a session that is stuck, and a channel that cries wolf is one you switch off."
758
+ "body": "3.7.0 could tell you a session was waiting — but only when Claude Code asked permission to run a tool. If you work with permissions bypassed, that never happens, and the whole feature was silent for you. Measured on one real log: 1683 events, every one of them in bypassPermissions, and a single permission prompt in the entire history.\n\nWhat fires constantly on that setup is the other kind: the agent asking you a question and stopping for the answer. The deck was throwing those away — no amber chip, no row in the sidebar, no notification, and no \"notify me\" button, because the button only appeared while something was actually blocked.\n\nNow they count. The card carries the question itself rather than a generic \"Your turn\" — \"paycore needs your input: merge both branches to main, or just one?\" — and so does the notification, on the desktop, whether the tab is behind something or not open at all.\n\nAn idle prompt still stays quiet: a turn that ended is not a session that is stuck, and a channel that cries wolf is one you switch off."
730
759
  }
731
760
  ],
732
761
  "3.7.0": [
733
762
  {
734
763
  "title": "🔔 The deck can reach you when you are not looking at it",
735
- "body": "The deck's whole point is telling you which agent is waiting on you, and until now it could only say so on screen — the amber chip, the tab title, the favicon. Every one of those is drawn inside a page, so the one moment it matters, when you have walked away and four agents have gone quiet, the deck was silent.\n\nNow it reaches the desktop, two ways that never overlap. With the deck open behind something, the page raises the notification itself: a \"notify me\" button appears in the top bar the first time a session blocks, and the browser asks you — nothing is requested before there is something to notify you about. With no page open at all, the server raises it, because it can see that nobody is connected and therefore that no other surface is saying anything.\n\nOne notification per session, replaced rather than stacked if a second deck raises it too. Clicking it brings the tab forward. `AGENTS_DECK_NO_NOTIFY=1` turns the desktop side off entirely, and if you refuse the browser prompt the deck says so plainly instead of leaving you thinking you switched something on — after a refusal only your browser's site settings can undo it, which no page is allowed to touch."
764
+ "body": "The deck's whole point is telling you which agent is waiting on you, and until now it could only say so on screen — the amber chip, the tab title, the favicon. Every one of those is drawn inside a page, so the one moment it matters, when you have walked away and four agents have gone quiet, the deck was silent.\n\nNow it reaches the desktop, two ways that never overlap. With the deck open behind something, the page raises the notification itself: the browser asks for permission when you turn notifications on in Settings › Notifications — nothing is requested before you do. With no page open at all, the server raises it, because it can see that nobody is connected and therefore that no other surface is saying anything.\n\nOne notification per session, replaced rather than stacked if a second deck raises it too. Clicking it brings the tab forward. `AGENTS_DECK_NO_NOTIFY=1` turns the desktop side off entirely, and if you refuse the browser prompt the deck says so plainly instead of leaving you thinking you switched something on — after a refusal only your browser's site settings can undo it, which no page is allowed to touch."
736
765
  },
737
766
  {
738
767
  "title": "🧩 A blocked session now says what it is asking for",
@@ -762,7 +791,7 @@
762
791
  },
763
792
  {
764
793
  "title": "🧹 The board's own figure is out of the Usage panel",
765
- "body": "3.3.0 put the canvas's live spend on its own labelled line, under whichever period you had chosen. In front of a panel that answers today, this month and all time from the logs on disk, that second money figure answers a question nobody asked at that moment — and it invited a comparison it could never win, $7,385 on the board beneath $170 for today.\n\nIt is gone. The board's tokens and cost are still on the topbar, with the sentence that explains why they fall on their own, and the panel still shows them when ccusage cannot answer at all."
794
+ "body": "3.3.0 put the canvas's live spend on its own labelled line, under whichever period you had chosen. In front of a panel that answers today, this month and all time from the logs on disk, that second money figure answers a question nobody asked at that moment — and it invited a comparison it could never win, $7,385 on the board beneath $170 for today.\n\nIt is gone. The panel still shows the board's tokens and cost when ccusage cannot answer at all."
766
795
  }
767
796
  ],
768
797
  "3.3.0": [
@@ -776,7 +805,7 @@
776
805
  },
777
806
  {
778
807
  "title": "👁 Browser Watch reads nothing while it is switched off",
779
- "body": "The switch used to control only what was KEPT. The read happened anyway — every five minutes, on every deck, the panel's badge poll discovered each Chromium profile and copied its whole History database to a temp file to query it. A switch that says off while that continues is not a switch.\n\nWith the watch off, nothing touches a browser now. Opening the panel still reads live, because that is you looking, and a watch that is ON still records in the background — that is the feature. The trade is that the topbar badge no longer counts findings while the watch is off; open the panel to see them.\n\nThe README has a Browser Watch section now: what is read, when, where the copy goes, and that only visits after the deck started are ever considered."
808
+ "body": "The switch used to control only what was KEPT. The read happened anyway — every five minutes, on every deck, the panel's badge poll discovered each Chromium profile and copied its whole History database to a temp file to query it. A switch that says off while that continues is not a switch.\n\nWith the watch off, nothing touches a browser now. Opening the panel still reads live, because that is you looking, and a watch that is ON still records in the background — that is the feature. The trade is that the Browser Watch badge no longer counts findings while the watch is off; open the panel to see them.\n\nThe README has a Browser Watch section now: what is read, when, where the copy goes, and that only visits after the deck started are ever considered."
780
809
  },
781
810
  {
782
811
  "title": "🔒 The deck's own data is no longer readable by anything on the machine",
@@ -846,7 +875,7 @@
846
875
  "1.48.0": [
847
876
  {
848
877
  "title": "👁 Browser Watch — see what a program opened while you were away",
849
- "body": "There is a new eye in the top bar. It watches the browsers on this machine and tells you when a program — an extension, an agent, a command — opened pages while nobody was touching the browser.\n\nUsually that is your own agent doing what you asked, and the panel says so rather than crying intrusion. What it gives you is the evidence: which pages, in which browser, at what time, with every address written out in full.\n\nIt is off until you switch it on, and nothing leaves this machine. It reads a copy of the browser's own history and only pages newer than the moment the deck started — never anything from before, never cookies, never saved passwords. Open ⚙ inside the panel and \"What Browser Watch can access\" says exactly what it reads, keeps and sends.\n\nWhen it finds something the eye turns amber with a count. You can dismiss a finding once you have looked at it and it stays gone, while the log file on disk keeps the record. On a Mac it can also close the tab it found, or quit the browser."
878
+ "body": "Browser Watch is new, on the window's right edge (or press B). It watches the browsers on this machine and tells you when a program — an extension, an agent, a command — opened pages while nobody was touching the browser.\n\nUsually that is your own agent doing what you asked, and the panel says so rather than crying intrusion. What it gives you is the evidence: which pages, in which browser, at what time, with every address written out in full.\n\nIt is off until you switch it on, and nothing leaves this machine. It reads a copy of the browser's own history and only pages newer than the moment the deck started — never anything from before, never cookies, never saved passwords. Open ⚙ inside the panel and \"What Browser Watch can access\" says exactly what it reads, keeps and sends.\n\nWhen it finds something, its button shows a count. You can dismiss a finding once you have looked at it and it stays gone, while the log file on disk keeps the record. On a Mac it can also close the tab it found, or quit the browser."
850
879
  },
851
880
  {
852
881
  "title": "🔑 Move all your accounts to another machine in one paste",
@@ -866,7 +895,7 @@
866
895
  "1.46.0": [
867
896
  {
868
897
  "title": "🔊 Pick a volume and a sound for each of the two tones",
869
- "body": "Click the speaker in the top bar and a small menu opens. There are two tones — one when a turn finishes, one when Claude is waiting for you — and each has its own volume and its own sound, six to choose from. Press \"Hear it\" to try one straight away, without waiting for a real turn.\n\nThe two lists are separate on purpose, so you can never end up with both tones sounding the same. A finish tone always falls; an asking tone always rises. That is how you tell them apart without looking at the screen.\n\nM still turns the sound on and off from anywhere."
898
+ "body": "Open Settings › Sounds, or press V. There are two tones — one when a turn finishes, one when Claude is waiting for you — and each has its own volume and its own sound, six to choose from. Press \"Hear it\" to try one straight away, without waiting for a real turn.\n\nThe two lists are separate on purpose, so you can never end up with both tones sounding the same. A finish tone always falls; an asking tone always rises. That is how you tell them apart without looking at the screen.\n\nM still turns the sound on and off from anywhere."
870
899
  },
871
900
  {
872
901
  "title": "✨ This box is new",
@@ -33,10 +33,16 @@
33
33
  // the same GitLab project — settings, jobs, branches. Fourteen rows
34
34
  // describe that worse than one card does. That is what toEpisodes is for.
35
35
  // * Without exclusions the deck reports ITSELF: 41 visits to 127.0.0.1:4317
36
- // and 34 to 127.0.0.1:4399 carry FROM_API, because ccdeck opens its own tab
37
- // through `open` on every start (bin/deck.js) and the listen fallback range
36
+ // and 34 to 127.0.0.1:4399 carry FROM_API, and the listen fallback range
38
37
  // means it is not always the same port. defaultExclusions() is that, and
39
- // only that.
38
+ // only that. This note used to say `open` was the reason — ccdeck opens its
39
+ // own tab with it on every start (bin/deck.js). Retested on 2026-10-14, a
40
+ // URL that `open` (macOS, Chrome 155) or `xdg-open` (Linux, Brave 1.95)
41
+ // hands to a running Chromium is recorded as AUTO_TOPLEVEL WITHOUT the bit,
42
+ // so those 75 visits most likely came from something else that loaded the
43
+ // deck's page (they were FROM_API over LINK, the shape an extension's
44
+ // tabs.update leaves). The exclusion stands either way: the deck's own
45
+ // origin does collect FROM_API visits.
40
46
  //
41
47
  // WHAT "NOBODY WAS AT THE BROWSER" MEANS HERE. It is derived from the visit
42
48
  // list itself: a human visit is any visit WITHOUT the bit. There is no OS idle
@@ -65,8 +71,25 @@
65
71
  // reports program navigation; the person reading the card decides what it was.
66
72
 
67
73
  /** Chrome's PAGE_TRANSITION_FROM_API qualifier — bit 27 of `visits.transition`.
68
- * Set when the navigation was started through an API rather than by a person
69
- * in the UI, which covers `open`, the debugger protocol, and an extension. */
74
+ * Chromium defines it only as "originated from an external application", so
75
+ * what sets it is Chrome's own code, and it is narrower than "any API":
76
+ *
77
+ * SET by a page loaded with the DevTools protocol's `Page.navigate`, whatever
78
+ * transition type was asked for (content/browser/devtools/protocol/
79
+ * page_handler.cc), which is how Playwright and Puppeteer go to an address,
80
+ * and by an extension's `chrome.tabs.update` with a URL
81
+ * (chrome/browser/extensions/api/tabs/tabs_api.cc).
82
+ *
83
+ * NOT SET by `Target.createTarget` with a URL (AUTO_TOPLEVEL,
84
+ * chrome/browser/devtools/chrome_devtools_manager_delegate.cc), an
85
+ * extension's `chrome.tabs.create` (LINK, extensions/open_tab_helper.cc), a
86
+ * link a program clicks, or a URL handed over by `open` or `xdg-open`
87
+ * (AUTO_TOPLEVEL, chrome/browser/ui/startup/startup_browser_creator_impl.cc).
88
+ *
89
+ * Tested on 2026-10-14 on Brave 1.95 (Chromium 153, Linux) and Chrome 155
90
+ * (macOS 26.6) with throwaway profiles. The NOT SET ones are invisible to this
91
+ * module, and since a visit without the bit counts as a person (classify),
92
+ * they can also cancel a program's flagged page within the quiet window. */
70
93
  export const FROM_API = 0x08000000;
71
94
 
72
95
  /** The same bit for the arbitrary-precision path below. */
@@ -227,11 +250,11 @@ function hostParts(text) {
227
250
  * Exclusion matchers for the deck's own pages, from the origins it is listening
228
251
  * on — `["http://127.0.0.1:4317"]`, or whatever the fallback range settled on.
229
252
  *
230
- * This is not politeness. ccdeck opens its own tab through `open`, so every
231
- * start writes a FROM_API visit to the deck's own origin, and those visits land
232
- * exactly where the rule is most sensitive: nobody is browsing at the moment a
233
- * background process opens a tab. Unfiltered, the feature's loudest and most
234
- * frequent finding is the feature itself.
253
+ * This is not politeness. The measured profile held FROM_API visits to the
254
+ * deck's own origin (the header's note says why `open` is not their cause),
255
+ * and those visits land exactly where the rule is most sensitive: nobody is
256
+ * browsing while a program loads the deck's page. Unfiltered, the feature's
257
+ * loudest and most frequent finding is the feature itself.
235
258
  *
236
259
  * Each origin expands to its port on all three loopback spellings, because the
237
260
  * deck opens 127.0.0.1 and a returning user may have typed localhost, and one
@@ -34,8 +34,9 @@ import { isProcessAlive } from "./deck-probe.mjs";
34
34
  * `portRange`), so a machine that has been running decks for a month has tabs
35
35
  * on several. The real profile this feature was tuned against carried 41 visits
36
36
  * to 127.0.0.1:4317 and 34 to 127.0.0.1:4399 — two ports, both this deck, both
37
- * FROM_API because `open` is an API call, and every one of them a card the
38
- * panel would have shown its owner about itself.
37
+ * FROM_API (not from `open`, which left no FROM_API when tested: see FROM_API in
38
+ * agent-activity.mjs), and every one of them a card the panel would have shown
39
+ * its owner about itself.
39
40
  *
40
41
  * The whole range rather than the ports seen: the alternative is to remember
41
42
  * which ports past decks used, which is a file to keep, a file to migrate, and
@@ -664,10 +664,9 @@ async function readProfile(profile, { sinceChromeTime, copyDir, deps, quietMs, o
664
664
  * Everything the panel draws, in one object.
665
665
  *
666
666
  * `deckOrigins` are the addresses this deck is listening on. They are excluded
667
- * by default and it is not an optimisation: ccdeck opens its own tab through
668
- * `open` on every start, which Chrome records with the same FROM_API bit as any
669
- * other program, so a watch without them reports the deck as the intruder every
670
- * time it launches.
667
+ * by default and it is not an optimisation: the deck's own origin collects
668
+ * FROM_API visits like any other program's page (see defaultExclusions), so a
669
+ * watch without them reports the deck as the intruder.
671
670
  */
672
671
  export async function browserWatchSnapshot({
673
672
  deckOrigins = [],
@@ -244,8 +244,8 @@ async function readCodexHeader(path) {
244
244
  // write it to the log they share. The event is still buffered and broadcast —
245
245
  // every deck watching the session draws it — it is only the second copy on disk
246
246
  // that is dropped. See writesCodexLog in log-election.mjs.
247
- function emitCodexEvent(payload, persist) {
248
- pushEvent(payload, "codex", { persist });
247
+ function emitCodexEvent(payload, persist, toolAuditAt) {
248
+ pushEvent(payload, "codex", { persist, toolAuditAt });
249
249
  }
250
250
 
251
251
  /**
@@ -405,7 +405,7 @@ function emitCodexLines(state, consume, persist) {
405
405
  }
406
406
  if (payload) {
407
407
  ensureCodexRoot(state, persist);
408
- emitCodexEvent(payload, persist);
408
+ emitCodexEvent(payload, persist, typeof obj.timestamp === "string" ? Date.parse(obj.timestamp) : undefined);
409
409
  }
410
410
  }
411
411
  }
@@ -9,6 +9,7 @@
9
9
  // imports reach pushEvent through event-sink.mjs, connected below as this
10
10
  // module loads; index.mjs's routes import it from here. The bodies are
11
11
  // unchanged.
12
+ import { noteToolAudit } from "./tool-audit.mjs";
12
13
  import { PRODUCT } from "./brand.mjs";
13
14
  import { createBlockNotifier } from "./block-notify.mjs";
14
15
  import { notify as osNotify } from "./browser-react.mjs";
@@ -122,6 +123,7 @@ export function pushEvent(raw, source, opts = {}) {
122
123
  // Numbered, charged, held, and the ring evicted behind it — see admitEvent.
123
124
  const evt = admitEvent(raw, source, opts.receivedAt ?? Date.now());
124
125
  const seq = evt.seq;
126
+ noteToolAudit(raw, source, opts);
125
127
 
126
128
  // Does this event reach the log at all? Not on a replay (it came from
127
129
  // there), not when the hook told us another deck owns this session's log,
@@ -139,6 +139,7 @@ export function upstreamForm({ fields, images }, facts) {
139
139
  if (contact) text("contact", contact);
140
140
  text("appVersion", facts.appVersion);
141
141
  text("platform", facts.platform);
142
+ if (facts.deviceId) text("deviceId", facts.deviceId);
142
143
  for (const [index, { data, format }] of images.entries()) {
143
144
  const { type, extension } = FORMAT[format];
144
145
  parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
@@ -164,6 +164,8 @@ import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettin
164
164
  // The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
165
165
  import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl, handleRadioStations, handleRadioClick, handleMusicConfig } from "./music-routes.mjs";
166
166
  // The usage panel's quota and history reads — see usage-routes.mjs.
167
+ import { startToolAudit } from "./tool-audit.mjs";
168
+ import { handleToolAudit } from "./tool-audit-routes.mjs";
167
169
  import { startQuotaHistory } from "./quota-history.mjs";
168
170
  import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
169
171
  // The port fallback, one listen attempt, and the words for a failed one — see
@@ -192,6 +194,7 @@ export function requestUrl(rawUrl) {
192
194
 
193
195
  export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
194
196
  startQuotaHistory();
197
+ await startToolAudit();
195
198
  armLifecycle({ onRestart, onStop, persist, onSelfUpdate: () => reporter.noteSelfUpdate() });
196
199
  // Which tree and which CLIs, normalised once — see setDeckScope.
197
200
  setDeckScope({ workspace, claude, codex });
@@ -318,6 +321,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
318
321
  if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
319
322
  if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
320
323
  if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
324
+ if (["GET", "POST"].includes(req.method) && ["/api/tool-audit", "/api/tool-audit/storage"].includes(url.pathname)) return guard(handleToolAudit(req, res), res);
321
325
  if (req.method === "GET" && url.pathname === "/api/quota/history") return guard(handleQuotaHistory(req, res), res);
322
326
  if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
323
327
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
@@ -53,6 +53,7 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
53
53
  */
54
54
  const PINNED_MODULES = [
55
55
  "self-update.mjs",
56
+ "tool-audit.mjs",
56
57
  "claude-accounts.mjs",
57
58
  "account-health.mjs",
58
59
  "cswap-admin.mjs",
@@ -7,7 +7,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
7
7
  import { MAX_REQUEST_BYTES, isMultipart, readFeedbackForm, upstreamForm } from "./feedback-form.mjs";
8
8
  import { readBody, readBytes, send } from "./http-io.mjs";
9
9
  import { heldPrefs } from "./prefs-state.mjs";
10
- import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
10
+ import { REPORTS_API, deviceIdToken, installFacts, reporter, reportsOn } from "./reports.mjs";
11
11
 
12
12
  async function readJson(req, res, limit) {
13
13
  const raw = await readBody(req, res, limit).catch(() => null);
@@ -72,35 +72,54 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
72
72
  * one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
73
73
  * that it "turns off everything but the quota reads", and a launch script's
74
74
  * word about the machine outranks a button on the page.
75
+ *
76
+ * `device` is the fingerprint feedbackDeviceId picks; a test passes its own,
77
+ * or null for none.
75
78
  */
76
- export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
79
+ export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env, device = feedbackDeviceId({ env }) } = {}) {
77
80
  if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
81
+ // Left out of the post entirely when there is none: JSON drops an undefined.
82
+ const deviceId = device || undefined;
78
83
  const contentType = req.headers?.["content-type"];
79
- if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env });
84
+ if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId });
80
85
  const body = await readJson(req, res, 32_000);
81
86
  if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
82
87
  const facts = feedbackFacts({ env });
83
88
  const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
84
89
  return forwardFeedback(res, fetchImpl, {
85
90
  headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.appVersion}` },
86
- body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts }),
91
+ body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts, deviceId }),
87
92
  timeoutMs: JSON_TIMEOUT_MS,
88
93
  });
89
94
  }
90
95
 
91
96
  /**
92
- * What this deck adds to a piece of feedback, and all it adds: the ccdeck
93
- * version, and the system as `os-arch` — `darwin-arm64`. installFacts knows
94
- * more than that (the channel, the runtime, a locale, a device fingerprint)
95
- * and none of it goes with feedback. One function for both posts and for the
96
- * line the dialog draws before Send, so what the page says is sent and what
97
- * the API is sent are one answer and cannot drift apart.
97
+ * What the dialog says, before Send, that this deck adds to a piece of
98
+ * feedback: the ccdeck version, and the system as `os-arch` — `darwin-arm64`.
99
+ * Both posts carry these two, from this one function, so what the line shows
100
+ * and what the API gets for them cannot drift apart.
101
+ *
102
+ * The posts also carry the device fingerprint (feedbackDeviceId), and the line
103
+ * does not show it: the owner's decision of 2026-10-14, so a report can be
104
+ * tied to the install that sent it. installFacts' other fields (the channel,
105
+ * the runtime, a locale) never go with feedback.
98
106
  */
99
107
  export function feedbackFacts({ env = process.env } = {}) {
100
108
  const facts = installFacts({ env });
101
109
  return { appVersion: facts.version, platform: `${facts.os}-${facts.arch}` };
102
110
  }
103
111
 
112
+ /**
113
+ * The device fingerprint a piece of feedback carries: the same one-way hash
114
+ * every usage report sends (deviceIdToken), and under the same rule — only
115
+ * while reports are on. Switched off, or vetoed with AGENTS_DECK_NO_REPORTS=1,
116
+ * the fingerprint stays home here as it does everywhere else, and the feedback
117
+ * still goes. It is read here, never taken from the page.
118
+ */
119
+ export function feedbackDeviceId({ env = process.env, prefs = heldPrefs.current() } = {}) {
120
+ return reportsOn(prefs, env) ? deviceIdToken() : undefined;
121
+ }
122
+
104
123
  /** `GET /api/feedback`: what a report sent from here would carry besides its
105
124
  * words, for the dialog to say before Send. Nothing leaves the machine. */
106
125
  export function handleFeedbackFacts(_req, res, { env = process.env } = {}) {
@@ -113,7 +132,7 @@ const JSON_TIMEOUT_MS = 20_000;
113
132
  * seconds of upload before the API has read a byte of it. */
114
133
  const FORM_TIMEOUT_MS = 120_000;
115
134
 
116
- async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
135
+ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId }) {
117
136
  // Over the cap, readBytes has already answered 413 too_large and the 400
118
137
  // below is a no-op; this is the answer to a read that failed some other way.
119
138
  const bytes = await readBytes(req, res, MAX_REQUEST_BYTES, { ok: false, reason: "too_large" }).catch(() => null);
@@ -125,7 +144,7 @@ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
125
144
  return forwardFeedback(res, fetchImpl, {
126
145
  // No content type: fetch writes the form's own, boundary and all.
127
146
  headers: { "user-agent": `ccdeck/${facts.appVersion}` },
128
- body: upstreamForm(form, facts),
147
+ body: upstreamForm(form, { ...facts, deviceId }),
129
148
  timeoutMs: FORM_TIMEOUT_MS,
130
149
  });
131
150
  }
@@ -430,6 +430,8 @@ export function isAuthorizedDataRead(req) {
430
430
  * costs nothing: every fetch of both is in src/web, and a page's own GET
431
431
  * carries Sec-Fetch-Site: same-origin. */
432
432
  export const GUARDED_READS = new Set([
433
+ "/api/tool-audit",
434
+ "/api/tool-audit/storage",
433
435
  "/api/system/traffic-radar",
434
436
  "/api/system/traffic-radar/capture",
435
437
  "/api/system/traffic-radar/capture/event",