agent-dag 3.7.1 → 3.8.1
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/assets/{index-Cc7GEg4Y.js → index-sRuhstwH.js} +39 -35
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/block-notify.mjs +12 -4
- package/src/server/browser-react.mjs +33 -4
- package/src/server/deck-prefs.mjs +129 -0
- package/src/server/index.mjs +58 -2
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-sRuhstwH.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.1",
|
|
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.1": [
|
|
35
|
+
{
|
|
36
|
+
"title": "\ud83d\udd12 Security: update if the deck runs on a Mac",
|
|
37
|
+
"body": "A local process able to reach the deck's port \u2014 no token, no browser, no tab \u2014 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 \u2014 but the fix is the thing to have."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"3.8.0": [
|
|
41
|
+
{
|
|
42
|
+
"title": "\ud83d\udd15 Notifications now have an off switch",
|
|
43
|
+
"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."
|
|
44
|
+
}
|
|
45
|
+
],
|
|
34
46
|
"3.7.1": [
|
|
35
47
|
{
|
|
36
48
|
"title": "\ud83d\udd14 The notifications now fire on the thing that actually stops your agent",
|
|
@@ -133,11 +133,19 @@ export function shouldNotify(raw, { clients, replay, lastAt, now }) {
|
|
|
133
133
|
* to the OS.
|
|
134
134
|
*
|
|
135
135
|
* `notify` and `now` are injected so the suite drives this without a desktop
|
|
136
|
-
* and without a clock
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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.
|
|
139
146
|
*/
|
|
140
147
|
export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError }) {
|
|
148
|
+
const isEnabled = typeof enabled === "function" ? enabled : () => enabled;
|
|
141
149
|
/** session_id → when it was last announced. Bounded by pruning on read: a
|
|
142
150
|
* long-lived server sees many sessions and this must not become a second
|
|
143
151
|
* ring nobody empties. */
|
|
@@ -146,7 +154,7 @@ export function createBlockNotifier({ notify, product, now = Date.now, enabled =
|
|
|
146
154
|
return {
|
|
147
155
|
/** Returns what it did, for the tests and for nothing else. */
|
|
148
156
|
consider(raw, { clients, replay = false }) {
|
|
149
|
-
if (!
|
|
157
|
+
if (!isEnabled()) return "off";
|
|
150
158
|
const at = now();
|
|
151
159
|
const id = raw?.session_id ?? "";
|
|
152
160
|
if (!shouldNotify(raw, { clients, replay, lastAt: seen.get(id), now: at })) return "skipped";
|
|
@@ -102,10 +102,26 @@ export async function notify(title, body, platform = process.platform, deps = {}
|
|
|
102
102
|
// shell tool this descends from built its notification by interpolation and
|
|
103
103
|
// that is the one place it had left the pattern it had banned everywhere
|
|
104
104
|
// else.
|
|
105
|
+
//
|
|
106
|
+
// AND `--` BEFORE THE OPERANDS, WHICH IS THE HALF THAT WAS MISSING. Passing
|
|
107
|
+
// a string as argv is not the same as it being treated as data: osascript's
|
|
108
|
+
// option parser reads any LEADING-DASH operand as an option, so a title
|
|
109
|
+
// beginning with `-e` was taken as a second script chunk and COMPILED. The
|
|
110
|
+
// note above asserted the argv form made an attacker-chosen host name safe;
|
|
111
|
+
// it did not.
|
|
112
|
+
//
|
|
113
|
+
// The reach was the whole of it. `title` is `basename(cwd) — ccdeck` from
|
|
114
|
+
// block-notify.mjs, `cwd` arrives in the body of `POST /api/event`, and
|
|
115
|
+
// that route is in OPEN_MUTATIONS — no token, no browser identity, which is
|
|
116
|
+
// exactly the sandboxed-subprocess-with-loopback-egress case the gate
|
|
117
|
+
// comments name. Verified on Darwin 25.5 with no payload at all: without
|
|
118
|
+
// `--`, a leading `-e` operand answers "The run handler is specified more
|
|
119
|
+
// than once", which is the compiler reporting on the attacker's string;
|
|
120
|
+
// with `--`, the same string arrives as `item 1 of argv`.
|
|
105
121
|
const r = await exec("osascript", [
|
|
106
122
|
"-e",
|
|
107
123
|
'on run argv\ndisplay notification (item 2 of argv) with title (item 1 of argv)\nend run',
|
|
108
|
-
title, body,
|
|
124
|
+
"--", title, body,
|
|
109
125
|
]).catch(() => null);
|
|
110
126
|
return r?.ok === true;
|
|
111
127
|
}
|
|
@@ -158,7 +174,14 @@ export async function notify(title, body, platform = process.platform, deps = {}
|
|
|
158
174
|
], { env: { ...process.env, CCDECK_TOAST_TITLE: title, CCDECK_TOAST_BODY: body } }).catch(() => null);
|
|
159
175
|
return r?.ok === true;
|
|
160
176
|
}
|
|
161
|
-
|
|
177
|
+
// `--` for the same reason as the darwin branch above, and it is worth having
|
|
178
|
+
// even though the consequence here is smaller: notify-send parses a leading
|
|
179
|
+
// dash as an option too, so an attacker-chosen title could suppress or
|
|
180
|
+
// misdirect the notification. There is no script compiler behind it, so this
|
|
181
|
+
// is a broken notification rather than an execution — fixed anyway, because
|
|
182
|
+
// the rule is "never let a user string be read as an option" and a rule with
|
|
183
|
+
// an exception is a rule somebody will apply to the wrong call next time.
|
|
184
|
+
const r = await exec("notify-send", ["--", title, body]).catch(() => null);
|
|
162
185
|
return r?.ok === true;
|
|
163
186
|
}
|
|
164
187
|
|
|
@@ -172,7 +195,10 @@ export async function closeTab(browserKey, url, platform = process.platform, dep
|
|
|
172
195
|
// and says so in its own contract, so `osascript -` would read an empty
|
|
173
196
|
// script and report success having done nothing. The URL still travels as
|
|
174
197
|
// argv, which is the part that matters.
|
|
175
|
-
|
|
198
|
+
// `--` here too. The url comes from a real http(s) history row so it cannot
|
|
199
|
+
// begin with a dash today, but that is a property of the caller rather than
|
|
200
|
+
// of this line, and the next caller will not know it.
|
|
201
|
+
const r = await exec("osascript", ["-e", closeTabScript(app), "--", url]).catch(() => null);
|
|
176
202
|
if (!r?.ok) return { ok: false, reason: "script_failed" };
|
|
177
203
|
const said = String(r.stdout ?? "").trim();
|
|
178
204
|
return { ok: said === "closed", reason: said || "unknown" };
|
|
@@ -185,7 +211,10 @@ export async function quitBrowser(browserKey, platform = process.platform, deps
|
|
|
185
211
|
const exec = deps.run ?? run;
|
|
186
212
|
if (platform === "darwin") {
|
|
187
213
|
const r = await exec("osascript", [
|
|
188
|
-
|
|
214
|
+
// `--`, as everywhere else that hands osascript an operand. `app` comes
|
|
215
|
+
// from a fixed table so it is safe by construction; the separator costs
|
|
216
|
+
// nothing and means no reader has to go and check that.
|
|
217
|
+
"-e", 'on run argv\ntell application (item 1 of argv) to quit\nend run', "--", app,
|
|
189
218
|
]).catch(() => null);
|
|
190
219
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
|
|
191
220
|
}
|
|
@@ -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.
|