agent-dag 3.22.0 → 3.22.3

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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,243 +0,0 @@
1
- // Where the deck keeps its OWN things, as opposed to where Claude Code keeps
2
- // its things.
3
- //
4
- // For a year those were one directory. `~/.claude/agent-dag/` held the hook
5
- // Claude Code reads, the deck's private key, its pairings, its port registry
6
- // and a hundred megabytes of event log — and the only reason was that the deck
7
- // already knew that path, because it has to read `~/.claude` to do its job at
8
- // all.
9
- //
10
- // THREE THINGS THAT COST, and none of them is tidiness:
11
- //
12
- // A person who deletes ~/.claude to fix Claude Code deletes this deck's
13
- // identity. The key is what every paired machine has pinned, so it is not a
14
- // setting that comes back — every colleague has to accept this deck again.
15
- //
16
- // CLAUDE_CONFIG_DIR is Claude Code's knob for keeping a second profile, and
17
- // pointing it somewhere gave the deck a brand new identity and no pairings,
18
- // silently. That is a footgun with a fifty-machine blast radius and no error
19
- // message anywhere.
20
- //
21
- // The event log is a hundred megabytes of the deck's data inside another
22
- // application's configuration directory. Nothing about a rotating log is
23
- // configuration.
24
- //
25
- // So the deck's own state moves to where this platform keeps application data,
26
- // and the two things that CANNOT move stay where they are: `hook.js`, because
27
- // Claude Code reads it from there, and the per-pid port registry, because a
28
- // deck of any version has to be able to list the others — see
29
- // registeredDeckPorts, which reads that directory by name.
30
- //
31
- // The layout is env-paths', which is what Node command-line tools converge on,
32
- // and its rule is the one worth repeating: XDG is a Linux specification, so it
33
- // applies on Linux and nowhere else. macOS has Application Support and Logs;
34
- // Windows has LocalAppData. A tool that puts ~/.config on a Mac is a tool that
35
- // read half the page.
36
- //
37
- // NOTHING IS DELETED BY THE MOVE. Every file is COPIED to its new home and the
38
- // original left where it was, so a deck downgraded to the version before this
39
- // one finds exactly what it had. See migrateDeckFiles.
40
- import { homedir } from "node:os";
41
- import { join, posix as posixPath, win32 as winPath } from "node:path";
42
- import { claudeConfigDir } from "./claude-dir.mjs";
43
- // The Windows ladder, not `rename` (#786). MoveFileExW refuses while any other
44
- // process holds the destination open — a scanner, the indexer, a backup agent —
45
- // and the file this one renames into place is a private key. Losing that rename
46
- // to a virus scanner would be a deck with no identity, which is the one outcome
47
- // this whole module is written to prevent.
48
- import { renameWithRetry } from "./installer.mjs";
49
-
50
- /** The name this deck files itself under. No `-nodejs` suffix, which env-paths
51
- * adds to keep a script from colliding with a native app of the same name —
52
- * there is no other ccdeck on any of these platforms. */
53
- const APP = "ccdeck";
54
-
55
- /** Point the deck's whole home somewhere else in one variable. The escape
56
- * hatch for a portable install, a test, or anybody who simply wants it
57
- * elsewhere; it wins over every rule below. */
58
- export const HOME_ENV = "CCDECK_HOME";
59
-
60
- /** Where the deck kept everything before this, and where two of its files still
61
- * live. Also the place a migration reads from. */
62
- export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
63
- const { join } = platform === "win32" ? winPath : posixPath;
64
- return join(claudeConfigDir(env, home), "agent-dag");
65
- }
66
-
67
- /**
68
- * The deck's own durable state: its key, its pairings, the accounts it offers.
69
- *
70
- * CLAUDE_CONFIG_DIR IS HONOURED RATHER THAN OVERRIDDEN, and that is deliberate.
71
- * Somebody who set it has asked for a separate Claude profile and has been
72
- * getting a separate deck along with it for a year — separate identity,
73
- * separate pairings, separate share list. Moving those people to one
74
- * machine-wide deck would collapse two profiles into one on upgrade, which is
75
- * exactly the kind of silent change this file exists to stop. They keep what
76
- * they have; everybody else moves.
77
- */
78
- export function deckDataDir(platform = process.platform, env = process.env, home = homedir()) {
79
- // THE TARGET PLATFORM'S SEPARATOR, NOT THE HOST'S — the same reason
80
- // claudeCliCandidates does it, which is that the only way a Windows answer
81
- // stays right is if it can be checked from a Mac. `node:path` joins with
82
- // whatever the machine running the code uses, so a bare `join` here answers
83
- // `\home\u\Library\...` on Windows for a question about macOS.
84
- // `resolve` for the same reason as `join` just below it: it is host-bound
85
- // too, so a posix path resolved on a Windows runner comes back with the
86
- // runner's drive letter glued to the front of it.
87
- const { join, resolve } = platform === "win32" ? winPath : posixPath;
88
- const forced = env[HOME_ENV]?.trim();
89
- if (forced) return resolve(forced);
90
- if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
91
- if (platform === "darwin") return join(home, "Library", "Application Support", APP);
92
- if (platform === "win32") return join(localAppData(env, home, platform), APP, "Data");
93
- return join(env.XDG_DATA_HOME?.trim() || join(home, ".local", "share"), APP);
94
- }
95
-
96
- /**
97
- * Where a log belongs, which is not where a preference belongs.
98
- *
99
- * On Linux that is the state directory rather than the data one: XDG separates
100
- * "data the user would miss" from "state the program can rebuild", and an event
101
- * log that rotates at 50 MB is the second. macOS and Windows have a named place
102
- * for logs and this uses it.
103
- */
104
- export function deckLogDir(platform = process.platform, env = process.env, home = homedir()) {
105
- const { join, resolve } = platform === "win32" ? winPath : posixPath;
106
- const forced = env[HOME_ENV]?.trim();
107
- if (forced) return resolve(forced);
108
- if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
109
- if (platform === "darwin") return join(home, "Library", "Logs", APP);
110
- if (platform === "win32") return join(localAppData(env, home, platform), APP, "Log");
111
- return join(env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), APP);
112
- }
113
-
114
- /** %LOCALAPPDATA%, or the path it points at on every Windows since Vista. The
115
- * fallback is not decoration: a service account or a stripped environment can
116
- * reach this code with the variable unset, and a deck that throws there is a
117
- * deck that will not start. */
118
- function localAppData(env = process.env, home = homedir(), platform = process.platform) {
119
- const { join } = platform === "win32" ? winPath : posixPath;
120
- return env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local");
121
- }
122
-
123
- /**
124
- * WHAT MOVES, and the two things that do not.
125
- *
126
- * `hook.js` stays: Claude Code reads it out of ~/.claude/agent-dag and would
127
- * find nothing if it moved. The per-pid `NNNNN.json` files stay: a deck lists
128
- * that directory to find the other decks on this machine, and a deck of the
129
- * previous version reads it by name — move them and two versions running side
130
- * by side stop seeing each other.
131
- *
132
- * `mode` is the file's, not the directory's, and 0600 on the preferences is
133
- * load-bearing: it is the one file with a private key in it.
134
- */
135
- export const MOVED = Object.freeze([
136
- Object.freeze({ name: "prefs.json", where: "data", mode: 0o600 }),
137
- Object.freeze({ name: "events.jsonl", where: "log", mode: 0o644 }),
138
- Object.freeze({ name: "events.jsonl.1", where: "log", mode: 0o644 }),
139
- ]);
140
-
141
- /**
142
- * Bring a deck's files to their new home, once, and never at the cost of one.
143
- *
144
- * FOUR RULES, and each one is a way this could have lost somebody's key:
145
- *
146
- * A destination that already exists is never touched. That is what makes
147
- * this safe to run at every boot, and it is also what stops a stale copy in
148
- * the old directory from overwriting live state later.
149
- *
150
- * The copy lands on a temp file in the destination directory and is renamed
151
- * into place. A copy interrupted half-way is a truncated prefs.json, and a
152
- * truncated prefs.json is a deck with no key — which is the whole thing this
153
- * is trying not to do.
154
- *
155
- * The original is LEFT WHERE IT IS. Somebody who downgrades finds what they
156
- * had. It costs four kilobytes and a hundred megabytes of log that was
157
- * already there, and it buys a move nobody can be hurt by.
158
- *
159
- * A failure is reported and swallowed. A read-only home, a full disk, a
160
- * permission the installer never had — none of those is a reason for the
161
- * deck not to start, and the caller falls back to reading the old path.
162
- *
163
- * Returns what it did, so the caller can say so once rather than every boot.
164
- */
165
- export async function migrateDeckFiles({
166
- from,
167
- data,
168
- log,
169
- fs,
170
- /** Separate from `fs` because it is not `fs.rename`: see the import above. */
171
- rename = renameWithRetry,
172
- onError,
173
- } = {}) {
174
- const moved = [];
175
- const failed = [];
176
- for (const file of MOVED) {
177
- const dir = file.where === "log" ? log : data;
178
- const src = join(from, file.name);
179
- const dst = join(dir, file.name);
180
- if (src === dst) continue;
181
- try {
182
- // Both questions asked before anything is written: there is something to
183
- // move, and there is nothing already there.
184
- if (!(await exists(fs, src))) continue;
185
- if (await exists(fs, dst)) continue;
186
- await fs.mkdir(dir, { recursive: true, mode: 0o700 });
187
- const tmp = `${dst}.${process.pid}.migrating`;
188
- await fs.copyFile(src, tmp);
189
- await fs.chmod(tmp, file.mode).catch(() => {});
190
- await rename(tmp, dst);
191
- moved.push(file.name);
192
- } catch (err) {
193
- failed.push(file.name);
194
- onError?.(file.name, err);
195
- }
196
- }
197
- return { moved, failed };
198
- }
199
-
200
- async function exists(fs, path) {
201
- try { await fs.stat(path); return true; } catch { return false; }
202
- }
203
-
204
- /** An hour. A temp file younger than this may belong to a write happening in
205
- * another deck right now, and deleting one of those turns an atomic write into
206
- * a lost one. Nothing legitimate is an hour old: the rename that retires a
207
- * temp file happens within milliseconds of its creation. */
208
- export const TEMP_STALE_MS = 60 * 60 * 1000;
209
-
210
- /**
211
- * The litter an atomic write leaves when the process is killed between the
212
- * write and the rename.
213
- *
214
- * Ninety-seven of them were found in one directory, the oldest six days old:
215
- * two waves, one from decks that crashed and one from decks killed by hand
216
- * mid-write. Nothing has ever swept them, because the code that makes them is
217
- * the code that is not running any more when they are made.
218
- *
219
- * So the sweep belongs at START, where a process that is alive can clean up
220
- * after the ones that are not — and it is deliberately blind to WHICH deck made
221
- * a file, because it cannot know: it goes by age instead, which is a fact about
222
- * the file rather than a guess about a pid.
223
- */
224
- export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
225
- let removed = 0;
226
- for (const dir of dirs ?? []) {
227
- let names;
228
- try { names = await fs.readdir(dir); } catch { continue; }
229
- for (const name of names) {
230
- if (!name.endsWith(".tmp") && !name.endsWith(".migrating")) continue;
231
- const path = join(dir, name);
232
- try {
233
- const st = await fs.stat(path);
234
- if (now - st.mtimeMs < TEMP_STALE_MS) continue;
235
- await fs.unlink(path);
236
- removed++;
237
- } catch (err) {
238
- onError?.(path, err);
239
- }
240
- }
241
- }
242
- return removed;
243
- }
@@ -1,301 +0,0 @@
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, writeFile } from "node:fs/promises";
30
- // The rename, with the Windows retry ladder installer.mjs wrote for exactly
31
- // this call. See the note over the write below (#786).
32
- import { renameWithRetry } from "./installer.mjs";
33
- import { join } from "node:path";
34
- import { deckDataDir } from "./deck-home.mjs";
35
-
36
- /** Set to "1" to keep the deck off the desktop whatever the stored preference
37
- * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
38
- * AGENTS_DECK_NO_INSTALL, and unchanged in meaning by this file. */
39
- export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
40
-
41
- /* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
42
- directory Claude Code owns — which meant a person clearing Claude Code's
43
- configuration cleared this deck's private key, and every machine that had
44
- pinned it had to be told to trust this one again. deck-home.mjs owns the new
45
- answer and the reasons; what matters here is that the parameter is still a
46
- DIRECTORY, so every caller that passes one is unchanged. */
47
- const prefsDir = (home = deckDataDir()) => home;
48
- export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
49
-
50
- /**
51
- * Every preference the deck keeps, with the answer it gives when there is no
52
- * file — which is the answer for every existing install, so it has to be the
53
- * behaviour those installs already have.
54
- *
55
- * `notifications` defaults ON because that is what 3.7.0 shipped and what the
56
- * release notes describe; a switch that quietly turned an existing feature off
57
- * on upgrade would be a worse surprise than the noise it is meant to stop.
58
- */
59
- export const DEFAULTS = Object.freeze({
60
- notifications: true,
61
- // Whether the deck may update itself: restart into code already on disk once
62
- // it is idle, and — while nobody is looking — install a newer release and
63
- // restart into that (auto-update.mjs). ON, because this is the banner's
64
- // `auto when idle`, which defaulted on as a localStorage key; it moved here so
65
- // the server can read it with no page open.
66
- autoUpdate: true,
67
- // LAN sync, off until somebody turns it on. `passphrase` is the only secret
68
- // this file has ever held, which is why the write below now names a mode.
69
- lan: Object.freeze({
70
- enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0,
71
- // WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Both on, so two
72
- // decks on one network find each other and pair themselves — which is what
73
- // a person with three of their own machines wants and had to do by hand
74
- // six times.
75
- //
76
- // Read this next to the two switches that gate it. `enabled` above is off,
77
- // so nothing here happens until somebody deliberately puts this deck on the
78
- // network; `shared` is empty, so a deck that pairs is offered nothing until
79
- // somebody ticks a login. These say what happens AFTER both of those, and
80
- // the dialog that turns the feature on prints them.
81
- autoAsk: true,
82
- autoAccept: true,
83
- // What somebody HERE calls another deck, keyed by its fingerprint. The name
84
- // a deck gives itself is its owner's to choose; this is the other half.
85
- aliases: Object.freeze({}),
86
- }),
87
- });
88
-
89
- /** The mode prefs.json is created with.
90
- *
91
- * It held nothing but booleans until LAN sync, and a booleans file at the
92
- * umask default is unremarkable. A group passphrase is not: on a shared
93
- * machine the default mode hands it to every other account on the box, and
94
- * from it they can decrypt any credential that crosses the network.
95
- *
96
- * Passed to `writeFile` rather than applied with a follow-up chmod, and the
97
- * difference is the whole point — claude-swap's transfer.py makes the same
98
- * argument at length: a write-then-chmod leaves the file readable for the
99
- * window between the two, which is exactly when a secret is in it. */
100
- export const PREFS_MODE = 0o600;
101
-
102
- /** The longest name somebody here may give another deck. The same order as a
103
- * deck's own name, and short enough to stay one line in the panel's column. */
104
- export const ALIAS_MAX = 48;
105
-
106
- /** A name somebody typed for another deck, cleaned — or "" for one with
107
- * nothing left in it, which is how an alias is taken away. Control characters
108
- * go the way cleanName sends them: this string reaches a terminal as well as a
109
- * page. */
110
- export function cleanAlias(raw) {
111
- if (typeof raw !== "string") return "";
112
- const flat = raw.replace(/\p{Cc}/gu, " ").replace(/\s+/g, " ").trim();
113
- return [...flat].slice(0, ALIAS_MAX).join("").trim();
114
- }
115
-
116
- /** A fingerprint an alias may be kept under. Real ones are `abc-def-012-345`;
117
- * what matters here is only that a typed address's placeholder — which has a
118
- * colon in it and names no deck — can never be one, and that nothing exotic
119
- * becomes a key in an object this file writes back to disk. */
120
- export function isAliasKey(fp) {
121
- return typeof fp === "string" && /^[A-Za-z0-9-]{1,64}$/.test(fp);
122
- }
123
-
124
- /** The alias map, coerced. A page writes it; a hand-edited file can hold
125
- * anything. Capped, because nothing legitimate is anywhere near it. */
126
- function aliasesFrom(raw) {
127
- const out = {};
128
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
129
- for (const [fp, name] of Object.entries(raw).slice(0, 256)) {
130
- const clean = cleanAlias(name);
131
- if (isAliasKey(fp) && clean) out[fp] = clean;
132
- }
133
- return out;
134
- }
135
-
136
- /** One LAN section, coerced. Unknown keys dropped like everything else here,
137
- * and the two lists forced to arrays of strings — they arrive from a page and
138
- * are then compared against account keys and dialled as addresses. */
139
- function normaliseLan(raw) {
140
- const src = raw && typeof raw === "object" ? raw : {};
141
- const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
142
- return {
143
- enabled: typeof src.enabled === "boolean" ? src.enabled : false,
144
- name: typeof src.name === "string" ? src.name : "",
145
- // THIS DECK'S PRIVATE KEY, and the only secret this file has ever held —
146
- // which is why the write below names a mode rather than taking the umask's.
147
- // It is an X25519 private key in base64 pkcs8; anything else is replaced on
148
- // the next start rather than refused, because a corrupt key is not a thing
149
- // anybody can act on and refusing to start would take the feature away.
150
- secret: typeof src.secret === "string" ? src.secret : "",
151
- shared: strings(src.shared),
152
- manual: strings(src.manual),
153
- // THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
154
- // half: a fingerprint is a hash of it, so an entry without one cannot be
155
- // checked against whatever answers at an address later, and an entry that
156
- // cannot be checked is worse than no entry at all.
157
- trusted: (Array.isArray(src.trusted) ? src.trusted : [])
158
- .filter(t => t && typeof t.fp === "string" && typeof t.pub === "string")
159
- .map(t => ({
160
- fp: t.fp, pub: t.pub, name: typeof t.name === "string" ? t.name : "",
161
- // When somebody here said yes. Absent on every pin made before this was
162
- // kept, and absent is drawn as "before" rather than guessed.
163
- ...(Number.isFinite(t.at) && t.at > 0 ? { at: t.at } : {}),
164
- })),
165
- aliases: aliasesFrom(src.aliases),
166
- // The port this deck listened on last time, so an address somebody typed on
167
- // the other machine still works after a restart. It asked the OS for a new
168
- // one every start, which is invisible while broadcast works and is exactly
169
- // what does not work in the case the address field exists for.
170
- //
171
- // A PREFERENCE, never a requirement: a port already taken falls through to
172
- // an OS-chosen one and this is rewritten. 0 means "none yet".
173
- port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
174
- // ASK FIRST. A deck heard on the broadcast is sent a pairing request without
175
- // anybody pressing `ask` — the outbound half, which gives nothing away: the
176
- // machine on the other end still answers it, by hand or by the switch below.
177
- autoAsk: typeof src.autoAsk === "boolean" ? src.autoAsk : true,
178
- // AND SAY YES. Every deck that finishes a handshake and is not already
179
- // trusted is pinned without anybody being asked — the accept button pressed
180
- // in advance, and it hands whoever asks a copy of every login this deck
181
- // shares. Absent means the default above; only a real boolean overrides it,
182
- // because a truthy string from a hand-edited file is not an answer.
183
- autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : true,
184
- };
185
- }
186
-
187
- /** Coerce whatever is on disk into a whole, known-shaped prefs object.
188
- *
189
- * Unknown keys are DROPPED rather than carried: this file is written by the
190
- * deck and read by the deck, and a key from a newer build that this one does
191
- * not understand cannot be honoured — keeping it would only mean writing back
192
- * a setting nothing here can see, which reads as support and is not. */
193
- export function normalise(raw) {
194
- const src = raw && typeof raw === "object" ? raw : {};
195
- return {
196
- notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
197
- autoUpdate: typeof src.autoUpdate === "boolean" ? src.autoUpdate : DEFAULTS.autoUpdate,
198
- lan: normaliseLan(src.lan),
199
- };
200
- }
201
-
202
- /** What is on disk, or the defaults. A corrupt or absent file is not an error
203
- * the user can act on mid-session, so it reads as "nothing chosen yet". */
204
- export async function readPrefs(home = deckDataDir(), deps = {}) {
205
- const read = deps.readFile ?? readFile;
206
- try { return normalise(JSON.parse(await read(prefsPath(home), "utf8"))); }
207
- catch { return { ...DEFAULTS }; }
208
- }
209
-
210
- let _chain = Promise.resolve();
211
-
212
- /**
213
- * Change some of the preferences, keeping the rest.
214
- *
215
- * A PATCH rather than a whole-state write, which is the opposite of the choice
216
- * browser-watch-store.mjs makes — and deliberately. That store holds a growing
217
- * archive where a merge would have to decide what wins between two decks; this
218
- * holds a handful of independent booleans, where "the field I did not mention
219
- * keeps its value" is the only sane reading and an omitted field erasing a
220
- * setting would be a bug with no upside.
221
- *
222
- * Serialized for the same reason the other store is: two pages toggling two
223
- * different switches in the same second must not lose one of them.
224
- */
225
- export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
226
- const job = async () => {
227
- const mk = deps.mkdir ?? mkdir;
228
- const write = deps.writeFile ?? writeFile;
229
- // `renameWithRetry`, not `rename` (#786). MoveFileExW refuses while any
230
- // handle without FILE_SHARE_DELETE is open on either side, and Defender and
231
- // the search indexer open a file the instant it is written — so on Windows
232
- // a bare rename fails on a perfectly healthy machine, `POST /api/prefs`
233
- // 500s through `guard`, and the notifications switch silently does not
234
- // stick. POSIX rename(2) has no such rule, which is why this shipped green.
235
- const mv = deps.rename ?? renameWithRetry;
236
- const prev = await readPrefs(home, deps);
237
- // The LAN section merges rather than replaces, so a page toggling the
238
- // switch does not have to send the passphrase back to keep it — and so
239
- // nothing has to send a secret it was never given.
240
- const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
241
- const next = normalise(merged);
242
- await mk(prefsDir(home), { recursive: true, mode: 0o700 });
243
- const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
244
- await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
245
- await mv(tmp, prefsPath(home));
246
- return next;
247
- };
248
- const started = _chain.then(job, job);
249
- _chain = started.then(() => {}, () => {});
250
- return started;
251
- }
252
-
253
- /**
254
- * May the deck raise a desktop notification right now?
255
- *
256
- * The env var wins. A machine launched with AGENTS_DECK_NO_NOTIFY=1 has been
257
- * told by whoever started it to stay off the desktop, and a page posting to
258
- * /api/prefs must not be able to overrule that — the person at the keyboard and
259
- * the person who wrote the launch script are not always the same person, and
260
- * only one of them is making a claim about the machine.
261
- */
262
- /**
263
- * The preferences as a PAGE may see them.
264
- *
265
- * THE PRIVATE KEY NEVER LEAVES THIS PROCESS. `GET /api/prefs` is readable by
266
- * anything that can reach the loopback port — which is the whole point of the
267
- * deck's own threat model, and is why the share envelope is not served there
268
- * either. A page has no use for the key: what it needs is this deck's
269
- * fingerprint, which is a hash of the PUBLIC half and comes from `/api/lan`.
270
- *
271
- * The pinned public keys of trusted decks go the same way, for a smaller
272
- * reason: they are not secret, and they are forty characters of base64 that no
273
- * page draws. What a page shows is a name and a fingerprint.
274
- */
275
- export function publicPrefs(prefs) {
276
- const p = normalise(prefs);
277
- const { secret, trusted, ...lan } = p.lan;
278
- return {
279
- ...p,
280
- lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
281
- };
282
- }
283
-
284
- export function notificationsOn(prefs, env = process.env) {
285
- if (env[OFF_ENV] === "1") return false;
286
- return normalise(prefs).notifications;
287
- }
288
-
289
- /**
290
- * Did the MACHINE veto this, as opposed to the person?
291
- *
292
- * Reported separately because the two are different sentences and the UI has to
293
- * say the right one. Deriving it from `notificationsOn` being false was the
294
- * first spelling, and the browser caught it immediately: switching the setting
295
- * off made the menu read "off — set at launch" on a deck launched with no
296
- * variable at all, telling the user their own press had been overruled by
297
- * something that had not happened.
298
- */
299
- export function notificationsVetoed(env = process.env) {
300
- return env[OFF_ENV] === "1";
301
- }
@@ -1,111 +0,0 @@
1
- // Is that process a deck, and is it still there — asked from anywhere.
2
- //
3
- // These four lived in src/server/index.mjs, which is the whole server: 5,800
4
- // lines, a dozen watchers, and timers that arm the moment the module is
5
- // imported. Everything that had to ask one of these questions therefore had to
6
- // import all of that, and three callers cannot:
7
- //
8
- // • running-deck.mjs, read on the boot path before anything else, which for
9
- // one commit took both of these as PARAMETERS purely to avoid the import.
10
- // • `ccdeck --stop` and `--status`, one-shot commands that talk to a deck and
11
- // exit. Starting a server to ask a server to stop is absurd on its face,
12
- // and on a cold start it is also slower than the thing it is asking for.
13
- // • the tests, which pin these against hook/hook.js's copy and should cost a
14
- // millisecond rather than a server boot.
15
- //
16
- // So they moved down here, to a leaf that imports two node builtins and nothing
17
- // else. index.mjs imports them from here and RE-EXPORTS them under the names it
18
- // always had, so every existing caller and every existing test is untouched —
19
- // and there is still exactly one spelling of the handshake in the package.
20
- //
21
- // hook/hook.js keeps its own duplicate of challengeProof for the one reason
22
- // that has always justified it: that script is copied out of the package and
23
- // installed into ~/.claude, where it cannot import from anywhere. A test pins
24
- // the two spellings against each other, and that test is the only thing
25
- // standing between a changed hash and a deck that silently stops being told
26
- // anything.
27
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
28
- import { request as httpRequest } from "node:http";
29
-
30
- // The same deadline hook.js gives a challenge, and for the same reason: a
31
- // bodyless GET to a loopback port is sub-millisecond when a deck is there and an
32
- // instant ECONNREFUSED when nothing is.
33
- export const DECK_CHALLENGE_TIMEOUT_MS = 400;
34
-
35
- // Signal 0 delivers nothing; it asks whether the pid could be signalled.
36
- //
37
- // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
38
- // answers EPERM for a process this account may not signal. On Windows
39
- // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
40
- // maps that to EACCES — so a deck started from an elevated terminal, or under
41
- // another account, read as DEAD to every probe in this repo. What followed was
42
- // silent: the live deck's discovery file was unlinked on the next hook fire,
43
- // rewritten five seconds later by keepDiscovery, and its banner went on
44
- // claiming it was receiving events it had stopped receiving.
45
- export function isProcessAlive(pid) {
46
- try { process.kill(pid, 0); return true; }
47
- catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
48
- }
49
-
50
- /**
51
- * The proof of knowing `token`, for a nonce the challenger chose.
52
- *
53
- * hook/hook.js spells this out a second time — it is installed outside the
54
- * package and cannot import from here — and a test pins the two against each
55
- * other. Changing one without the other silently blinds the deck.
56
- */
57
- export function challengeProof(token, nonce) {
58
- return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
59
- }
60
-
61
- /**
62
- * One challenge round trip, resolving true only on a correct proof.
63
- *
64
- * The compare is constant-time for the reason hook.js's sameProof is: whatever
65
- * is on that port may not be a deck, and it must not be able to walk the
66
- * expected proof out of us one byte at a time by timing how long we take to hang
67
- * up. The nonce is fresh per call, so an answer overheard earlier is worth
68
- * nothing, and the token itself never leaves this process.
69
- */
70
- export function challengeDeck(port, token) {
71
- return new Promise(resolve => {
72
- let settled = false;
73
- const finish = ok => { if (settled) return; settled = true; resolve(ok); };
74
- const nonce = randomBytes(16).toString("hex");
75
- const want = challengeProof(token, nonce);
76
- const req = httpRequest({
77
- hostname: "127.0.0.1",
78
- port,
79
- path: `/api/hook-challenge?nonce=${nonce}`,
80
- method: "GET",
81
- timeout: DECK_CHALLENGE_TIMEOUT_MS,
82
- }, res => {
83
- if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
84
- let answer = "";
85
- res.setEncoding("utf8");
86
- res.on("data", c => {
87
- answer += c;
88
- // A deck answers in ~100 bytes. Anything pouring data at us is not one,
89
- // and must not be allowed to grow this buffer without bound.
90
- if (answer.length > 4096) { req.destroy(); finish(false); }
91
- });
92
- res.on("end", () => {
93
- if (settled) return;
94
- let proof;
95
- try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
96
- finish(sameProof(proof, want));
97
- });
98
- });
99
- req.on("error", () => finish(false));
100
- req.on("timeout", () => req.destroy());
101
- req.end();
102
- });
103
- }
104
-
105
- /** hook.js's sameProof, for the same reason it is constant-time there. */
106
- export function sameProof(got, want) {
107
- if (typeof got !== "string") return false;
108
- const a = Buffer.from(got, "utf8");
109
- const b = Buffer.from(want, "utf8");
110
- return a.length === b.length && timingSafeEqual(a, b);
111
- }