agent-dag 3.7.0 → 3.8.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/dist/web/index.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-Tzpp82OH.js"></script>
|
|
44
44
|
<link rel="stylesheet" crossorigin href="/assets/index-ByAgTqB8.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -31,6 +31,18 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.8.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "\ud83d\udd15 Notifications now have an off switch",
|
|
37
|
+
"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 \u2014 which no page can do for you \u2014 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 \u2014 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 \u2014 a machine told at launch to stay off the desktop stays off it \u2014 and when that is the case the menu says so instead of showing an \"on\" that will not happen."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"3.7.1": [
|
|
41
|
+
{
|
|
42
|
+
"title": "\ud83d\udd14 The notifications now fire on the thing that actually stops your agent",
|
|
43
|
+
"body": "3.7.0 could tell you a session was waiting \u2014 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 \u2014 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\" \u2014 \"paycore needs your input: merge both branches to main, or just one?\" \u2014 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."
|
|
44
|
+
}
|
|
45
|
+
],
|
|
34
46
|
"3.7.0": [
|
|
35
47
|
{
|
|
36
48
|
"title": "\ud83d\udd14 The deck can reach you when you are not looking at it",
|
|
@@ -54,16 +54,31 @@ export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
|
|
|
54
54
|
*/
|
|
55
55
|
export const QUIET_MS = 2 * 60 * 1000;
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
|
|
57
|
+
/**
|
|
58
|
+
* The events that mean a session has stopped and cannot start again without a
|
|
59
|
+
* human. Two of the three kinds CC emits.
|
|
60
|
+
*
|
|
61
|
+
* `permission_prompt` — it wants to run something and is waiting to be allowed.
|
|
62
|
+
*
|
|
63
|
+
* `agent_needs_input` — it asked a question and is waiting for the answer, with
|
|
64
|
+
* the question itself in `message`. This was missing, and its absence was not a
|
|
65
|
+
* small gap: on a machine running `bypassPermissions` Claude Code never asks to
|
|
66
|
+
* run anything, so `permission_prompt` essentially never fires and the desktop
|
|
67
|
+
* notification could not happen at all. Measured on one real log — 1683 events,
|
|
68
|
+
* every one of them bypassPermissions — a single permission prompt in the whole
|
|
69
|
+
* history against five of these.
|
|
70
|
+
*
|
|
71
|
+
* `idle_prompt` is deliberately still out: #348 measured 16 idle to 5
|
|
72
|
+
* permission, and an idle prompt is a turn that ended, not a session that is
|
|
73
|
+
* stuck. Three quarters noise is how a notification channel gets muted, and a
|
|
74
|
+
* muted channel is worse than none because the deck goes on believing it told
|
|
75
|
+
* somebody.
|
|
76
|
+
*/
|
|
77
|
+
export function isBlockingPrompt(raw) {
|
|
64
78
|
return !!raw
|
|
65
79
|
&& raw.hook_event_name === "Notification"
|
|
66
|
-
&& raw.notification_type === "permission_prompt"
|
|
80
|
+
&& (raw.notification_type === "permission_prompt"
|
|
81
|
+
|| raw.notification_type === "agent_needs_input");
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
@@ -96,7 +111,7 @@ export function blockNotice(raw, product) {
|
|
|
96
111
|
* notification — a channel that fires twelve times in a second is one the user
|
|
97
112
|
* turns off within the minute:
|
|
98
113
|
*
|
|
99
|
-
* - a
|
|
114
|
+
* - a blocking prompt, per `isBlockingPrompt`
|
|
100
115
|
* - NOTHING LISTENING. A page is a better surface than this in every way, so
|
|
101
116
|
* wherever there is one, this stays out of the way.
|
|
102
117
|
* - NOT A REPLAY. The server replays events.jsonl into itself at boot to
|
|
@@ -106,7 +121,7 @@ export function blockNotice(raw, product) {
|
|
|
106
121
|
* - the session has not just been announced, per `QUIET_MS`.
|
|
107
122
|
*/
|
|
108
123
|
export function shouldNotify(raw, { clients, replay, lastAt, now }) {
|
|
109
|
-
if (!
|
|
124
|
+
if (!isBlockingPrompt(raw)) return false;
|
|
110
125
|
if (clients > 0) return false;
|
|
111
126
|
if (replay) return false;
|
|
112
127
|
if (lastAt != null && now - lastAt < QUIET_MS) return false;
|
|
@@ -118,11 +133,19 @@ export function shouldNotify(raw, { clients, replay, lastAt, now }) {
|
|
|
118
133
|
* to the OS.
|
|
119
134
|
*
|
|
120
135
|
* `notify` and `now` are injected so the suite drives this without a desktop
|
|
121
|
-
* and without a clock
|
|
122
|
-
*
|
|
123
|
-
*
|
|
136
|
+
* and without a clock.
|
|
137
|
+
*
|
|
138
|
+
* `enabled` USED TO BE READ ONCE at construction, on the argument that a switch
|
|
139
|
+
* changing under a running process is one two events in the same second can
|
|
140
|
+
* disagree about. That was right while the only way to set it was an
|
|
141
|
+
* environment variable, which cannot change under a running process at all. It
|
|
142
|
+
* is a user-facing switch now — deck-prefs.mjs, flipped from the sound menu —
|
|
143
|
+
* and a mute that only takes effect after a restart is not a mute. So it may be
|
|
144
|
+
* a function, asked per event; two events in the same second disagreeing is the
|
|
145
|
+
* correct behaviour when somebody pressed the switch between them.
|
|
124
146
|
*/
|
|
125
147
|
export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError }) {
|
|
148
|
+
const isEnabled = typeof enabled === "function" ? enabled : () => enabled;
|
|
126
149
|
/** session_id → when it was last announced. Bounded by pruning on read: a
|
|
127
150
|
* long-lived server sees many sessions and this must not become a second
|
|
128
151
|
* ring nobody empties. */
|
|
@@ -131,7 +154,7 @@ export function createBlockNotifier({ notify, product, now = Date.now, enabled =
|
|
|
131
154
|
return {
|
|
132
155
|
/** Returns what it did, for the tests and for nothing else. */
|
|
133
156
|
consider(raw, { clients, replay = false }) {
|
|
134
|
-
if (!
|
|
157
|
+
if (!isEnabled()) return "off";
|
|
135
158
|
const at = now();
|
|
136
159
|
const id = raw?.session_id ?? "";
|
|
137
160
|
if (!shouldNotify(raw, { clients, replay, lastAt: seen.get(id), now: at })) return "skipped";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// The deck's own small settings, and the first of them: whether it may reach
|
|
2
|
+
// the desktop.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. Everything the deck could be told before this was told
|
|
5
|
+
// through an environment variable read once at boot — AGENTS_DECK_NO_NOTIFY,
|
|
6
|
+
// AGENTS_DECK_NO_INSTALL, AGENTS_DECK_NO_DOWNLOAD. That is the right shape for
|
|
7
|
+
// "never do this on this machine", set by whoever launches the deck, and the
|
|
8
|
+
// wrong shape for a switch a person wants to flip because the notifications are
|
|
9
|
+
// annoying them right now: it means quitting the deck and re-running it with a
|
|
10
|
+
// variable in front, which is a lot to ask of somebody whose complaint is that
|
|
11
|
+
// the deck is being noisy.
|
|
12
|
+
//
|
|
13
|
+
// So there is one preference file, and the notification switch lives in it. The
|
|
14
|
+
// env var is NOT retired and is not a default either — it is an override that
|
|
15
|
+
// wins, because a machine told at launch to stay off the desktop must stay off
|
|
16
|
+
// it whatever a page later posts. See `notificationsOn`.
|
|
17
|
+
//
|
|
18
|
+
// WHY NOT IN THE BROWSER. The desktop notifier runs in the SERVER, on the case
|
|
19
|
+
// where no page exists at all — so a preference kept in localStorage could not
|
|
20
|
+
// reach the code it governs at the moment that code runs. It also has to be one
|
|
21
|
+
// answer per machine rather than one per browser profile: two browsers open on
|
|
22
|
+
// the same deck are one deck, and a switch that meant something different in
|
|
23
|
+
// each would be a switch nobody could reason about.
|
|
24
|
+
//
|
|
25
|
+
// The write is the atomic one browser-watch-store.mjs argues for at length —
|
|
26
|
+
// temp file, rename — because the alternative is a truncated JSON document as
|
|
27
|
+
// the only record of what the user chose, and a corrupt file here silently
|
|
28
|
+
// turns the notifications back on.
|
|
29
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
32
|
+
|
|
33
|
+
/** Set to "1" to keep the deck off the desktop whatever the stored preference
|
|
34
|
+
* says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
|
|
35
|
+
* AGENTS_DECK_NO_INSTALL, and unchanged in meaning by this file. */
|
|
36
|
+
export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
|
|
37
|
+
|
|
38
|
+
const prefsDir = (home = claudeConfigDir()) => join(home, "agent-dag");
|
|
39
|
+
export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "prefs.json");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every preference the deck keeps, with the answer it gives when there is no
|
|
43
|
+
* file — which is the answer for every existing install, so it has to be the
|
|
44
|
+
* behaviour those installs already have.
|
|
45
|
+
*
|
|
46
|
+
* `notifications` defaults ON because that is what 3.7.0 shipped and what the
|
|
47
|
+
* release notes describe; a switch that quietly turned an existing feature off
|
|
48
|
+
* on upgrade would be a worse surprise than the noise it is meant to stop.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULTS = Object.freeze({ notifications: true });
|
|
51
|
+
|
|
52
|
+
/** Coerce whatever is on disk into a whole, known-shaped prefs object.
|
|
53
|
+
*
|
|
54
|
+
* Unknown keys are DROPPED rather than carried: this file is written by the
|
|
55
|
+
* deck and read by the deck, and a key from a newer build that this one does
|
|
56
|
+
* not understand cannot be honoured — keeping it would only mean writing back
|
|
57
|
+
* a setting nothing here can see, which reads as support and is not. */
|
|
58
|
+
export function normalise(raw) {
|
|
59
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
60
|
+
return { notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What is on disk, or the defaults. A corrupt or absent file is not an error
|
|
64
|
+
* the user can act on mid-session, so it reads as "nothing chosen yet". */
|
|
65
|
+
export async function readPrefs(home = claudeConfigDir(), deps = {}) {
|
|
66
|
+
const read = deps.readFile ?? readFile;
|
|
67
|
+
try { return normalise(JSON.parse(await read(prefsPath(home), "utf8"))); }
|
|
68
|
+
catch { return { ...DEFAULTS }; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let _chain = Promise.resolve();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Change some of the preferences, keeping the rest.
|
|
75
|
+
*
|
|
76
|
+
* A PATCH rather than a whole-state write, which is the opposite of the choice
|
|
77
|
+
* browser-watch-store.mjs makes — and deliberately. That store holds a growing
|
|
78
|
+
* archive where a merge would have to decide what wins between two decks; this
|
|
79
|
+
* holds a handful of independent booleans, where "the field I did not mention
|
|
80
|
+
* keeps its value" is the only sane reading and an omitted field erasing a
|
|
81
|
+
* setting would be a bug with no upside.
|
|
82
|
+
*
|
|
83
|
+
* Serialized for the same reason the other store is: two pages toggling two
|
|
84
|
+
* different switches in the same second must not lose one of them.
|
|
85
|
+
*/
|
|
86
|
+
export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
|
|
87
|
+
const job = async () => {
|
|
88
|
+
const mk = deps.mkdir ?? mkdir;
|
|
89
|
+
const write = deps.writeFile ?? writeFile;
|
|
90
|
+
const mv = deps.rename ?? rename;
|
|
91
|
+
const next = normalise({ ...(await readPrefs(home, deps)), ...patch });
|
|
92
|
+
await mk(prefsDir(home), { recursive: true });
|
|
93
|
+
const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
|
|
94
|
+
await write(tmp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
95
|
+
await mv(tmp, prefsPath(home));
|
|
96
|
+
return next;
|
|
97
|
+
};
|
|
98
|
+
const started = _chain.then(job, job);
|
|
99
|
+
_chain = started.then(() => {}, () => {});
|
|
100
|
+
return started;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* May the deck raise a desktop notification right now?
|
|
105
|
+
*
|
|
106
|
+
* The env var wins. A machine launched with AGENTS_DECK_NO_NOTIFY=1 has been
|
|
107
|
+
* told by whoever started it to stay off the desktop, and a page posting to
|
|
108
|
+
* /api/prefs must not be able to overrule that — the person at the keyboard and
|
|
109
|
+
* the person who wrote the launch script are not always the same person, and
|
|
110
|
+
* only one of them is making a claim about the machine.
|
|
111
|
+
*/
|
|
112
|
+
export function notificationsOn(prefs, env = process.env) {
|
|
113
|
+
if (env[OFF_ENV] === "1") return false;
|
|
114
|
+
return normalise(prefs).notifications;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Did the MACHINE veto this, as opposed to the person?
|
|
119
|
+
*
|
|
120
|
+
* Reported separately because the two are different sentences and the UI has to
|
|
121
|
+
* say the right one. Deriving it from `notificationsOn` being false was the
|
|
122
|
+
* first spelling, and the browser caught it immediately: switching the setting
|
|
123
|
+
* off made the menu read "off — set at launch" on a deck launched with no
|
|
124
|
+
* variable at all, telling the user their own press had been overruled by
|
|
125
|
+
* something that had not happened.
|
|
126
|
+
*/
|
|
127
|
+
export function notificationsVetoed(env = process.env) {
|
|
128
|
+
return env[OFF_ENV] === "1";
|
|
129
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -15,7 +15,8 @@ import { promisify } from "node:util";
|
|
|
15
15
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
16
16
|
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
17
17
|
import { PRODUCT } from "./brand.mjs";
|
|
18
|
-
import { createBlockNotifier
|
|
18
|
+
import { createBlockNotifier } from "./block-notify.mjs";
|
|
19
|
+
import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
19
20
|
import { notify as osNotify } from "./browser-react.mjs";
|
|
20
21
|
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
21
22
|
import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
|
|
@@ -3131,13 +3132,66 @@ function redactDeckToken(raw) {
|
|
|
3131
3132
|
* has no notification daemon" mid-session, the deck is not broken by it, and
|
|
3132
3133
|
* every in-page surface still says everything it said before.
|
|
3133
3134
|
*/
|
|
3135
|
+
/**
|
|
3136
|
+
* The deck's own settings, in memory, refreshed whenever they are written.
|
|
3137
|
+
*
|
|
3138
|
+
* Read once at boot and then kept here rather than read per event: `consider`
|
|
3139
|
+
* is on the ingest path every hook event goes through, and a file read there
|
|
3140
|
+
* would be a syscall per event to answer a question that changes when somebody
|
|
3141
|
+
* presses a switch. The write path below updates this, so the in-memory copy
|
|
3142
|
+
* and the file cannot drift within one process — and a second deck writing the
|
|
3143
|
+
* file is picked up on ITS next write or this one's next boot, which is the
|
|
3144
|
+
* same freshness every other cross-deck setting has.
|
|
3145
|
+
*/
|
|
3146
|
+
let _prefs = { ...PREF_DEFAULTS };
|
|
3147
|
+
readPrefs().then(p => { _prefs = p; }).catch(() => {});
|
|
3148
|
+
|
|
3134
3149
|
const blockNotifier = createBlockNotifier({
|
|
3135
3150
|
notify: osNotify,
|
|
3136
3151
|
product: PRODUCT,
|
|
3137
|
-
|
|
3152
|
+
// A function, not a boolean: this is a switch a person flips from the sound
|
|
3153
|
+
// menu while the deck is running, and a mute that waited for a restart would
|
|
3154
|
+
// not be a mute. The env var still wins inside `notificationsOn`.
|
|
3155
|
+
enabled: () => notificationsOn(_prefs),
|
|
3138
3156
|
onError: err => console.error(`${PRODUCT}: could not raise a desktop notification:`, err?.message ?? err),
|
|
3139
3157
|
});
|
|
3140
3158
|
|
|
3159
|
+
/**
|
|
3160
|
+
* GET the deck's settings, and what the machine is allowing.
|
|
3161
|
+
*
|
|
3162
|
+
* THREE fields, not two, and the third is the one a first attempt got wrong.
|
|
3163
|
+
* `notificationsAllowed` is the effective answer, after the environment
|
|
3164
|
+
* variable has had its say — but "false" there means either "the user switched
|
|
3165
|
+
* it off" or "the machine forbids it", and the menu has to say a different
|
|
3166
|
+
* sentence for each. Deriving the second from the first made the switch read
|
|
3167
|
+
* "off — set at launch" the moment anybody turned it off on a deck launched
|
|
3168
|
+
* with no variable at all, which is the deck telling the user their own press
|
|
3169
|
+
* was somebody else's doing. `notificationsVetoed` answers only the machine's
|
|
3170
|
+
* half.
|
|
3171
|
+
*/
|
|
3172
|
+
function prefsPayload() {
|
|
3173
|
+
return {
|
|
3174
|
+
ok: true,
|
|
3175
|
+
prefs: _prefs,
|
|
3176
|
+
notificationsAllowed: notificationsOn(_prefs),
|
|
3177
|
+
notificationsVetoed: notificationsVetoed(),
|
|
3178
|
+
};
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
function handlePrefsRead(req, res) {
|
|
3182
|
+
return send(res, 200, prefsPayload());
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
/** POST a patch. Fields nobody sent keep their value — see writePrefs. */
|
|
3186
|
+
async function handlePrefsWrite(req, res) {
|
|
3187
|
+
const raw = await readBody(req).catch(() => null);
|
|
3188
|
+
let body = null;
|
|
3189
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
3190
|
+
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
3191
|
+
_prefs = await writePrefs(body);
|
|
3192
|
+
return send(res, 200, prefsPayload());
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3141
3195
|
function pushEvent(raw, source, opts = {}) {
|
|
3142
3196
|
// First, before anything below can see it: the deck's own credential does not
|
|
3143
3197
|
// belong in a store that is served without one. Every entry point to the
|
|
@@ -5364,6 +5418,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5364
5418
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
5365
5419
|
// Machine state, not session state: sampled on the server's own timer and
|
|
5366
5420
|
// deliberately kept out of the event stream. See src/server/system-metrics.mjs.
|
|
5421
|
+
if (req.method === "GET" && url.pathname === "/api/prefs") return handlePrefsRead(req, res);
|
|
5422
|
+
if (req.method === "POST" && url.pathname === "/api/prefs") return guard(handlePrefsWrite(req, res), res);
|
|
5367
5423
|
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|
|
5368
5424
|
// On demand only — the process list costs a subprocess on every platform,
|
|
5369
5425
|
// so it is fetched while the detail panel is open and never on the timer.
|