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.
- package/README.md +1 -1
- package/dist/web/assets/{BrowserWatchModal-B8m4UFih.js → BrowserWatchModal-CN2fFFCe.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DMrkxas9.js → UsageHistoryModal-CbBjBbZV.js} +1 -1
- package/dist/web/assets/{index-BzFGrdm1.css → index-DFTb6plM.css} +1 -1
- package/dist/web/assets/index-DFezFf9U.js +296 -0
- package/dist/web/index.html +2 -2
- package/package.json +18 -8
- package/release-notes.json +26 -1
- package/src/server/claude-fm.mjs +219 -0
- package/src/server/index.mjs +44 -0
- package/dist/web/assets/index-D3oqs0dv.js +0 -296
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
48
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
4
|
-
"description": "Live
|
|
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
|
-
"
|
|
45
|
+
"monitor",
|
|
46
|
+
"monitoring",
|
|
42
47
|
"observability",
|
|
48
|
+
"usage",
|
|
49
|
+
"cost",
|
|
50
|
+
"tokens",
|
|
51
|
+
"quota",
|
|
52
|
+
"hooks",
|
|
43
53
|
"dag",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
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://
|
|
67
|
+
"homepage": "https://ccdeck.dev",
|
|
58
68
|
"devDependencies": {
|
|
59
69
|
"@types/dagre": "^0.7.54",
|
|
60
70
|
"@types/node": "^22.20.1",
|
package/release-notes.json
CHANGED
|
@@ -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": [
|
|
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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|