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.
@@ -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-Bvo5gtk3.js"></script>
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.7.0",
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": {
@@ -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
- /** The one event that means a session has stopped and cannot start again
58
- * without a human. `idle_prompt` is the other kind CC emits and is deliberately
59
- * not here #348 measured 16 idle to 5 permission, and an idle prompt is a
60
- * turn that ended, not a session that is stuck. Three quarters noise is how a
61
- * notification channel gets muted, and a muted channel is worse than none
62
- * because the deck goes on believing it told somebody. */
63
- export function isPermissionPrompt(raw) {
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 permission prompt, per `isPermissionPrompt`
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 (!isPermissionPrompt(raw)) return false;
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, and `enabled` is read once at construction rather than
122
- * per event — a switch that can change under a running process is a switch two
123
- * events in the same second can disagree about.
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 (!enabled) return "off";
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
+ }
@@ -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, OFF_ENV as NO_NOTIFY_ENV } from "./block-notify.mjs";
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
- enabled: process.env[NO_NOTIFY_ENV] !== "1",
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.