ccdeck 3.23.4 → 3.24.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.
@@ -44,8 +44,8 @@
44
44
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
45
45
  })();
46
46
  </script>
47
- <script type="module" crossorigin src="/assets/index-D3oqs0dv.js"></script>
48
- <link rel="stylesheet" crossorigin href="/assets/index-BzFGrdm1.css">
47
+ <script type="module" crossorigin src="/assets/index-DFezFf9U.js"></script>
48
+ <link rel="stylesheet" crossorigin href="/assets/index-DFTb6plM.css">
49
49
  </head>
50
50
  <body>
51
51
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.23.4",
4
- "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
3
+ "version": "3.24.0",
4
+ "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ccdeck": "bin/agent-dag.js"
@@ -32,18 +32,28 @@
32
32
  },
33
33
  "keywords": [
34
34
  "claude-code",
35
+ "claude-code-dashboard",
35
36
  "claude",
36
37
  "anthropic",
38
+ "codex",
39
+ "codex-cli",
40
+ "openai",
37
41
  "agents",
42
+ "ai-agents",
38
43
  "subagents",
39
- "visualization",
40
44
  "dashboard",
41
- "hooks",
45
+ "monitor",
46
+ "monitoring",
42
47
  "observability",
48
+ "usage",
49
+ "cost",
50
+ "tokens",
51
+ "quota",
52
+ "hooks",
43
53
  "dag",
44
- "deck",
45
- "graph",
46
- "live"
54
+ "visualization",
55
+ "cli",
56
+ "deck"
47
57
  ],
48
58
  "author": "Bargan Constantin",
49
59
  "license": "AGPL-3.0-only",
@@ -54,7 +64,7 @@
54
64
  "bugs": {
55
65
  "url": "https://github.com/BarganConstantin/ccdeck/issues"
56
66
  },
57
- "homepage": "https://github.com/BarganConstantin/ccdeck#readme",
67
+ "homepage": "https://ccdeck.dev",
58
68
  "devDependencies": {
59
69
  "@types/dagre": "^0.7.54",
60
70
  "@types/node": "^22.20.1",
@@ -39,7 +39,32 @@
39
39
  "the release whose version is in neither place. Any key opening with // is a",
40
40
  "note to the author and never a release."
41
41
  ],
42
- "//nothing-to-say": ["3.23.1", "3.23.2", "3.23.3", "3.23.4"],
42
+ "//nothing-to-say": [
43
+ "3.23.1",
44
+ "3.23.2",
45
+ "3.23.3",
46
+ "3.23.4"
47
+ ],
48
+ "3.24.0": [
49
+ {
50
+ "title": "🎧 Claude FM plays from the minimap",
51
+ "body": "A small character stands on the minimap's top edge. Press it and the Claude channel's live stream plays; press it again and it stops. It only turns up when the channel is actually broadcasting, so a deck with no network never shows it at all."
52
+ },
53
+ {
54
+ "title": "🤠 And it gets on with its day",
55
+ "body": "It walks that edge, tidies up what it finds, kicks a ball along it, sits with its legs over the side, and watches the board through a scope. Now and then it drops onto the canvas and ropes its way back up. Headphones on while the music plays, a hat when it does not."
56
+ }
57
+ ],
58
+ "3.23.5": [
59
+ {
60
+ "title": "🧭 The accounts panel opens on the account you are on",
61
+ "body": "Its windows, its resets and how old the numbers are — whole, at the top of the column. Every other account stands behind one row that says how many of them a switch could actually reach; press it to see them, or rest on it to be shown their names and how much room each has left."
62
+ },
63
+ {
64
+ "title": "⚙️ Auto-switch moved in there with them",
65
+ "body": "It is behind that same row now, after the accounts it switches between, rather than at the foot of the panel. The row says `auto 90%` or `auto off` so you can tell what the policy is doing without opening it — and says so in amber when it is armed with nowhere left to go."
66
+ }
67
+ ],
43
68
  "3.23.0": [
44
69
  {
45
70
  "title": "🧭 The accounts column leads with accounts",
@@ -0,0 +1,219 @@
1
+ // Is Claude FM on air? That one question, and nothing else.
2
+ //
3
+ // The deck can play the Claude channel's live stream (src/web/claude-fm.ts
4
+ // builds the embed). What it must not do is offer a control that does nothing,
5
+ // so something has to answer "is there anything to play right now" BEFORE the
6
+ // canvas draws a play control at all. This is that something.
7
+ //
8
+ // ── why a channel and not a video ───────────────────────────────────────────
9
+ //
10
+ // The obvious build hardcodes the video id of the stream. That id is a
11
+ // stranger's object: the broadcast ends and restarts under a new one, the
12
+ // upload is struck and reappears, the uploader turns embedding off. Any of
13
+ // those leaves a control on the canvas that presses and plays nothing until
14
+ // somebody ships a release.
15
+ //
16
+ // A CHANNEL id never changes — not when the handle changes, not when a stream
17
+ // ends and another begins. `/channel/<id>/live` resolves to whatever that
18
+ // channel is broadcasting at this instant, and the embed that plays it
19
+ // (`/embed/live_stream?channel=<id>`) resolves the same way, so neither half
20
+ // holds a video id at all.
21
+ //
22
+ // ── how "on air" is read ────────────────────────────────────────────────────
23
+ //
24
+ // Two marks together, because either alone is a guess:
25
+ //
26
+ // - the page's `<link rel="canonical">` is a `watch?v=` URL. When the channel
27
+ // is live, `/live` IS the watch page for the broadcast; when it is not,
28
+ // YouTube answers with the channel page, whose canonical is the channel.
29
+ // - `"isLive":true` appears in the page's own player payload. On its own this
30
+ // is weak — a recommendation rail can carry it about somebody else's
31
+ // stream — which is exactly why it is only ever read alongside the first.
32
+ //
33
+ // Both, or this reports `live: false` and the canvas draws nothing. HTML
34
+ // scraping is a fragile way to learn anything and this one is deliberately
35
+ // shallow: it looks for two marks, never parses, and treats every surprise as
36
+ // "not live". The failure it can have is a control that fails to appear, which
37
+ // is the failure a music toy is allowed to have.
38
+ //
39
+ // ── what this never does ────────────────────────────────────────────────────
40
+ //
41
+ // No request leaves this machine until a page asks. There is no boot probe and
42
+ // no timer: a headless deck, a deck on a plane, a deck nobody has opened, all
43
+ // of them call youtube.com exactly zero times. The first canvas to mount asks
44
+ // once and every deck in the house is answered from the cache below for the
45
+ // next ten minutes.
46
+
47
+ /** The Claude channel. Not a video id — see the header. */
48
+ export const CLAUDE_FM_CHANNEL = "UCV03SRZXJEz-hchIAogeJOg";
49
+
50
+ /** Where the deck looks. Always the channel's `/live`, never a watch URL. */
51
+ export const liveUrl = (channel = CLAUDE_FM_CHANNEL) =>
52
+ `https://www.youtube.com/channel/${encodeURIComponent(channel)}/live`;
53
+
54
+ /** A channel id is `UC` and 22 more of YouTube's base64url alphabet. Checked
55
+ * because this value can come from prefs.json, and a value from a file is an
56
+ * argument to a URL this module then fetches. Nothing malformed gets that far. */
57
+ export const isChannelId = (v) => typeof v === "string" && /^UC[A-Za-z0-9_-]{22}$/.test(v);
58
+
59
+ /** Ten minutes. A live stream does not start and stop often enough for a
60
+ * tighter number to buy anything, and every deck on this machine shares the
61
+ * one answer. */
62
+ export const CACHE_MS = 600_000;
63
+
64
+ /** A failed look is cached too, and for much less time: the usual cause is a
65
+ * network that is not there yet, and a laptop that has just joined a network
66
+ * should not wait out the full window to notice. */
67
+ export const MISS_CACHE_MS = 60_000;
68
+
69
+ /** A backstop, not a budget.
70
+ *
71
+ * The first build of this stopped reading at a fixed 600,000 bytes on the
72
+ * reasoning that a `<link rel="canonical">` lives in `<head>` and `<head>` is
73
+ * at the top of a document. It is not, here: YouTube inlines its player
74
+ * payload ahead of the link, and on the day this was written the canonical sat
75
+ * at byte 711,238 and `"isLive":true` at 752,415 of a 1.15MB page. The cap cut
76
+ * both off and the probe reported a live channel as silent.
77
+ *
78
+ * So the read below stops on CONTENT — the moment both marks are in hand —
79
+ * and this number only exists to end a page that will never carry them. Any
80
+ * fixed guess at where YouTube puts its own markup is a guess that expires. */
81
+ export const READ_LIMIT = 2_000_000;
82
+
83
+ /** Long enough for a slow link, short enough that a canvas is never waiting on
84
+ * it — the client draws nothing while this is in flight and the control simply
85
+ * appears when it lands. */
86
+ export const TIMEOUT_MS = 6_000;
87
+
88
+ /** YouTube answers anything that does not look like a browser with a consent
89
+ * interstitial, and an interstitial carries no player payload to read. */
90
+ const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
91
+
92
+ const CANONICAL = /<link\s+rel="canonical"\s+href="https:\/\/www\.youtube\.com\/watch\?v=([\w-]{11})"/;
93
+ const IS_LIVE = /"isLive"\s*:\s*true/;
94
+
95
+ /**
96
+ * The read, over a body that stops at READ_LIMIT.
97
+ *
98
+ * Exported so the test can drive it with a fixture instead of the network, and
99
+ * so the regexes above have exactly one caller.
100
+ */
101
+ export function readLiveMarks(html) {
102
+ const canonical = CANONICAL.exec(html ?? "");
103
+ // Both marks or nothing. `video` is reported for the record — the embed does
104
+ // not use it and must not: the id is what goes stale.
105
+ if (!canonical || !IS_LIVE.test(html)) return { live: false, video: null };
106
+ return { live: true, video: canonical[1] };
107
+ }
108
+
109
+ /**
110
+ * The floor under `?refresh=1`, in the spelling five other modules already use.
111
+ *
112
+ * The cache above bounds what this deck costs YouTube on its own; it bounds
113
+ * nothing at all against a page that asks to skip it. `?refresh=1` is a GET, so
114
+ * any page the user has open can send one in a loop, and without this each one
115
+ * would be another request to a third party — #580's shape, which is why
116
+ * codex-usage-forced-read-guard.test.ts counts the routes that accept a forced
117
+ * read and makes each one answer for what it spends.
118
+ *
119
+ * Pure and exported, like `maySelfPoll` and `mayAskNpm`, so the rule can be
120
+ * driven without driving the request it guards.
121
+ */
122
+ export const FORCE_POLL_MS = 60_000;
123
+
124
+ export function mayAskYouTube(force, lastAt, now = Date.now()) {
125
+ if (!force) return false;
126
+ return !(lastAt > 0) || now - lastAt >= FORCE_POLL_MS;
127
+ }
128
+
129
+ let _cache = null;
130
+ let _cacheAt = 0;
131
+ let _inflight = null;
132
+
133
+ /** Drops the cache. For tests, and for the `refresh=1` a reopened page sends. */
134
+ export function forgetClaudeFm() { _cache = null; _cacheAt = 0; _inflight = null; }
135
+
136
+ /**
137
+ * @param {{ force?: boolean, channel?: string, fetchImpl?: typeof fetch }} opts
138
+ * @returns {Promise<{ok: boolean, live: boolean, channel: string, video: string|null, why?: string}>}
139
+ */
140
+ export async function fetchClaudeFm({ force = false, channel, fetchImpl } = {}) {
141
+ const id = isChannelId(channel) ? channel : CLAUDE_FM_CHANNEL;
142
+ const now = Date.now();
143
+ const window = _cache?.live ? CACHE_MS : MISS_CACHE_MS;
144
+ // A forced read only skips the cache if the floor allows it; otherwise it is
145
+ // an ordinary read and gets the cached answer.
146
+ const forcing = mayAskYouTube(force, _cacheAt, now);
147
+ if (!forcing && _cache && _cache.channel === id && now - _cacheAt < window) return _cache;
148
+ // One look at a time. Three panels mounting at once is three callers and one
149
+ // request, which is the same rule /api/quota keeps for the same reason.
150
+ if (_inflight) return _inflight;
151
+
152
+ const get = fetchImpl ?? fetch;
153
+ _inflight = (async () => {
154
+ try {
155
+ const res = await get(liveUrl(id), {
156
+ // A browser User-Agent, because the answer to anything else is a
157
+ // consent interstitial with no player payload in it at all.
158
+ headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
159
+ signal: AbortSignal.timeout(TIMEOUT_MS),
160
+ redirect: "follow",
161
+ });
162
+ if (!res.ok) return settle({ ok: false, live: false, channel: id, video: null, why: `youtube answered ${res.status}` });
163
+ const html = await readUntilMarks(res, READ_LIMIT);
164
+ const marks = readLiveMarks(html);
165
+ return settle({ ok: true, live: marks.live, channel: id, video: marks.video });
166
+ } catch (err) {
167
+ // Offline, DNS, a timeout, a blocked host — one answer for all of them,
168
+ // because the canvas does the same thing with every one: draws nothing.
169
+ return settle({ ok: false, live: false, channel: id, video: null, why: String(err?.message ?? err) });
170
+ } finally {
171
+ _inflight = null;
172
+ }
173
+ })();
174
+ return _inflight;
175
+ }
176
+
177
+ function settle(answer) {
178
+ _cache = answer;
179
+ _cacheAt = Date.now();
180
+ return answer;
181
+ }
182
+
183
+ /**
184
+ * Reads a response body until both marks are in hand, then stops the transfer.
185
+ *
186
+ * `res.text()` pulls every byte of a 1.15MB page to answer a yes/no, and a
187
+ * fixed byte cap was worse than that — see READ_LIMIT for the day it reported a
188
+ * live channel as silent. This reads chunk by chunk and leaves as soon as the
189
+ * accumulated text carries both anchors, which on a live page is around 760KB
190
+ * and on a page that is not live is the whole thing (there is no second mark to
191
+ * wait for, so the backstop is what ends it).
192
+ *
193
+ * Cancelling the reader is what actually stops the download; returning early
194
+ * without it would leave the rest of the transfer running behind a promise
195
+ * nobody holds. `indexOf` on the accumulated string rather than a per-chunk
196
+ * test, because an anchor that straddles a chunk boundary is invisible to the
197
+ * second and the strings here are small enough that the repeated scan costs
198
+ * less than the bug would.
199
+ */
200
+ export async function readUntilMarks(res, limit = READ_LIMIT) {
201
+ if (!res.body?.getReader) return (await res.text()).slice(0, limit);
202
+ const reader = res.body.getReader();
203
+ const decoder = new TextDecoder();
204
+ let out = "";
205
+ try {
206
+ while (out.length < limit) {
207
+ const { done, value } = await reader.read();
208
+ if (done) break;
209
+ out += decoder.decode(value, { stream: true });
210
+ if (out.includes('rel="canonical"') && out.includes('"isLive":true')) break;
211
+ }
212
+ } finally {
213
+ try { await reader.cancel(); } catch { /* the socket is gone; nothing to stop */ }
214
+ }
215
+ // The loop checks the cap before pulling, so the chunk that crosses it is
216
+ // already in hand. Returning it whole would make `limit` advisory, and a
217
+ // caller that passed one asked for a bound, not a suggestion.
218
+ return out.length > limit ? out.slice(0, limit) : out;
219
+ }
@@ -5725,6 +5725,48 @@ async function handleCodexUsage(req, res) {
5725
5725
  send(res, 200, usage);
5726
5726
  }
5727
5727
 
5728
+ /**
5729
+ * Whether Claude FM is broadcasting — the one question the canvas's music
5730
+ * control needs answered before it draws itself.
5731
+ *
5732
+ * NO REQUEST LEAVES THIS MACHINE UNTIL A PAGE ASKS FOR IT. There is no boot
5733
+ * probe and no timer behind this route: a deck nobody has opened calls
5734
+ * youtube.com zero times, and the first canvas to mount answers every other one
5735
+ * for the next ten minutes out of claude-fm.mjs's cache.
5736
+ *
5737
+ * Two environment variables, in the shape the notification and LAN switches
5738
+ * already use (deck-prefs.mjs names both):
5739
+ *
5740
+ * AGENTS_DECK_NO_MUSIC=1 this deck never contacts YouTube at all. The
5741
+ * answer is a plain no and the canvas draws
5742
+ * nothing, which is the same thing it does when
5743
+ * the channel is off air — so the off switch
5744
+ * needs no second code path to test.
5745
+ * AGENTS_DECK_FM_CHANNEL=UC... play a different channel's live stream. The
5746
+ * built-in one is a channel id rather than a
5747
+ * video id precisely so it does not go stale,
5748
+ * but a channel can be renamed, retired or
5749
+ * handed over, and a deck that can be pointed
5750
+ * elsewhere in one line does not need a release
5751
+ * to keep working. Anything that is not a
5752
+ * well-formed channel id is ignored rather than
5753
+ * fetched.
5754
+ */
5755
+ async function handleClaudeFm(req, res) {
5756
+ if (process.env.AGENTS_DECK_NO_MUSIC === "1") {
5757
+ return send(res, 200, { ok: true, live: false, off: true });
5758
+ }
5759
+ const { fetchClaudeFm } = await import(
5760
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-fm.mjs")).href
5761
+ );
5762
+ const url = new URL(req.url, "http://localhost");
5763
+ const answer = await fetchClaudeFm({
5764
+ force: url.searchParams.get("refresh") === "1",
5765
+ channel: process.env.AGENTS_DECK_FM_CHANNEL,
5766
+ });
5767
+ send(res, 200, answer);
5768
+ }
5769
+
5728
5770
  async function handleCodexQuota(req, res) {
5729
5771
  const { fetchCodexQuota } = await import(
5730
5772
  pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
@@ -6072,6 +6114,7 @@ const PINNED_MODULES = [
6072
6114
  "ccusage.mjs",
6073
6115
  "browser-watch.mjs",
6074
6116
  "browser-watch-store.mjs",
6117
+ "claude-fm.mjs",
6075
6118
  // system-metrics.mjs's two, on the platforms that have them, and the one
6076
6119
  // installer.mjs reaches for while it rewrites the hooks.
6077
6120
  "macmon.mjs",
@@ -7366,6 +7409,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
7366
7409
  if (req.method === "POST" && url.pathname === "/api/claude-accounts/admin") return guard(handleClaudeAccountAdmin(req, res), res);
7367
7410
  if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
7368
7411
  if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
7412
+ if (req.method === "GET" && url.pathname === "/api/claude-fm") return guard(handleClaudeFm(req, res), res);
7369
7413
 
7370
7414
  // Through writeJsonArray rather than `send`, and through `guard` like every
7371
7415
  // route above it: this is the one answer whose size is the ring's size, and