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,183 +0,0 @@
1
- // The command line, parsed: everything the deck recognises, and everything it
2
- // does not.
3
- //
4
- // Its own module rather than a function inside bin/deck.js, for one reason —
5
- // importing bin/deck.js RUNS a deck. That file installs hooks, binds a port and
6
- // opens a browser at module scope, so there was no way to ask what `--prot`
7
- // parses to without starting a server to find out. Nothing here does any of
8
- // that: strings in, a plain object out, no I/O, no process, no terminal.
9
- //
10
- // bin/deck.js is the only caller. The parser is the whole of the module's
11
- // surface; the tables it matches against stay inside it.
12
-
13
- /**
14
- * Is this token a flag rather than somebody's value?
15
- *
16
- * A leading `-` and nothing else, because the alternatives are worse. Matching
17
- * the known flag list would refuse `--workspace --prot` — a typo eating the next
18
- * token is the same accident as a real flag eating it, and the one shape that
19
- * must be caught is the one nobody spelled right. Refusing every value that
20
- * begins with `-` would refuse `--port -1`, which is a number the user meant and
21
- * which deserves the port error below rather than a "missing value" one.
22
- *
23
- * So: a leading `-`, except a plain negative number. The negative-number carve
24
- * is the only exception, and it is small on purpose.
25
- *
26
- * WHAT THIS DOES ON EACH PLATFORM. Nothing, is the intent, to any real path.
27
- * POSIX absolute (`/srv/proj`), POSIX relative (`./sub`, `sub`), `~/proj`, a
28
- * Windows drive letter (`C:\Users\u\proj`, `c:/users/u/proj`), a UNC share
29
- * (`\\srv\share\proj`), a Windows long path (`\\?\C:\proj`) and a bare
30
- * `events.jsonl` all begin with something other than `-`, so all of them are
31
- * values. A drive letter is not a flag on any platform and is never read as one:
32
- * `C:` starts with `C`. What IS refused is a directory whose name really begins
33
- * with a dash, which the user can still pass as `./-weird` — a two-character
34
- * price for catching `--workspace $UNSET --no-persist`.
35
- */
36
- export function looksLikeFlag(token) {
37
- return typeof token === "string" && token.startsWith("-") && !/^-\d+(?:\.\d+)?$/.test(token);
38
- }
39
-
40
- /**
41
- * Is this a port the deck could bind? Digits only, inside the range Node's
42
- * `listen` accepts.
43
- *
44
- * `Number()` alone is far too willing: it takes `" 4500 "`, `0x10e4`, `1e3` and
45
- * `Infinity`, and turns everything else into the `NaN` that used to reach
46
- * `listen` and die there. The whole point of asking here is to answer BEFORE the
47
- * deck has installed hooks and probed for tools, and to answer about the string
48
- * the user actually typed. See bin/deck.js, which prints the flag and the value
49
- * back at them.
50
- */
51
- export function isPortValue(raw) {
52
- if (typeof raw !== "string" && typeof raw !== "number") return false;
53
- const s = String(raw).trim();
54
- if (!/^\d+$/.test(s)) return false;
55
- const n = Number(s);
56
- return n >= 0 && n <= 65535;
57
- }
58
-
59
- /**
60
- * Parse `process.argv.slice(2)`.
61
- *
62
- * Returns the flags that were set, plus two lists that are always present and
63
- * always arrays:
64
- *
65
- * `unknown` — every token the loop did not recognise, in the order it met
66
- * them. That list is the point of this module: the loop used
67
- * to have no `else`, so `ccdeck --prot 4500` booted on 4317 and
68
- * said nothing, and a typo was indistinguishable from a flag
69
- * that worked.
70
- * `incomplete` — every value-taking flag that was given no value it could
71
- * use, as `{ flag, expects }`, `flag` spelled the way the user
72
- * spelled it. bin/deck.js prints one row per entry.
73
- *
74
- * THE THREE FLAGS THAT TAKE A VALUE (`--port`/`-p`, `--workspace`, `--history`)
75
- * used to consume the next token with `args[++i]` whatever it was. That is right
76
- * for `--port 4500` — the value must never be re-examined as a token of its own,
77
- * or every correct command line would report its own port as an unknown option,
78
- * and a warning that fires on correct input is a warning everybody learns to
79
- * ignore. It was wrong for a value that is itself a flag (#697): `ccdeck
80
- * --workspace $PROJ --no-persist` with `PROJ` unset is, after word splitting,
81
- * `ccdeck --workspace --no-persist`. The deck scoped itself to a directory
82
- * called `--no-persist`, wrote to the shared events log anyway, and reported
83
- * neither — `unknown` stayed empty, because the token that would have gone in it
84
- * had been eaten.
85
- *
86
- * So the consume is conditional now, and it refuses three shapes:
87
- *
88
- * * the next token looks like a flag — NOT consumed, so the loop meets it on
89
- * the next pass and it is parsed as the flag it is, or reported as unknown.
90
- * That is also what fixes the supervisor case in bin/agent-dag.js: a
91
- * respawn appends `--port <bound>` to the user's argv, and an argv ending in
92
- * a bare `--workspace` used to eat the `--port` and drop the deck back on
93
- * 4317, out from under the tab the user was looking at.
94
- * * there is no next token at all — the trailing `--workspace`, which used to
95
- * set `undefined` and mean "the default", silently.
96
- * * the next token is empty or blank — consumed (it was quoted, so it was
97
- * meant as the value) but not used. `--workspace ""` is a variable that did
98
- * not expand, not a request for machine-wide capture, and answering it with
99
- * the widest possible scope is the one answer that cannot be recovered from.
100
- *
101
- * In all three the flag is left UNSET, so the deck falls back to its documented
102
- * default, and the flag is named in `incomplete` so the fallback is said out
103
- * loud rather than discovered later.
104
- *
105
- * A bare word is `unknown` too, and deliberately: the deck takes no positional
106
- * arguments at all, so `ccdeck ~/proj` is the same mistake as `ccdeck --workpace
107
- * ~/proj` — something the deck read and then did nothing with. It is also how
108
- * an unquoted path with a space in it becomes visible, which is a failure this
109
- * repo already knew about and could not previously report: `--workspace
110
- * C:\Users\John Smith\proj` reaches the parser as two arguments, and the second
111
- * one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
112
- */
113
- /**
114
- * The flags that do a thing and exit, rather than starting a deck.
115
- *
116
- * bin/agent-dag.js reads this before it decides whether to detach, and that is
117
- * the whole reason it exists as a list rather than as a condition written out
118
- * at the call site: a one-shot that detached would print its answer into a log
119
- * file and hand the terminal back empty. `ccdeck --version` detaching itself is
120
- * the shape of the bug this prevents.
121
- */
122
- export const ONE_SHOT = Object.freeze([
123
- "help", "version", "uninstall", "stop", "status", "logs",
124
- "install", "installService", "uninstallService",
125
- ]);
126
-
127
- /** Is this a command line that answers and leaves? */
128
- export function isOneShot(flags = {}) {
129
- return ONE_SHOT.some((k) => flags[k] === true);
130
- }
131
-
132
- export function parseArgs(args) {
133
- const out = { unknown: [], incomplete: [] };
134
- for (let i = 0; i < args.length; i++) {
135
- const a = args[i];
136
- // The value of the flag just matched, or `undefined` when there is nothing
137
- // usable there. Closes over `i` so it can decline to advance it: not
138
- // consuming is what hands the token back to the loop.
139
- const value = (expects) => {
140
- const next = args[i + 1];
141
- if (next === undefined || looksLikeFlag(next)) {
142
- out.incomplete.push({ flag: a, expects });
143
- return undefined;
144
- }
145
- i++;
146
- if (String(next).trim() === "") {
147
- out.incomplete.push({ flag: a, expects });
148
- return undefined;
149
- }
150
- return next;
151
- };
152
- // Assigned only when there is a value, so an unusable one leaves the key
153
- // absent and the deck on its default — see the doc comment.
154
- const set = (key, expects) => {
155
- const v = value(expects);
156
- if (v !== undefined) out[key] = v;
157
- };
158
- if (a === "-h" || a === "--help") out.help = true;
159
- else if (a === "-v" || a === "--version") out.version = true;
160
- else if (a === "-p" || a === "--port") set("port", "a port number");
161
- else if (a === "--no-open") out.noOpen = true;
162
- else if (a === "--new") out.new = true;
163
- else if (a === "--foreground") out.foreground = true;
164
- else if (a === "--stop") out.stop = true;
165
- else if (a === "--status") out.status = true;
166
- else if (a === "--logs") out.logs = true;
167
- else if (a === "--install") out.install = true;
168
- else if (a === "--install-service") out.installService = true;
169
- else if (a === "--uninstall-service") out.uninstallService = true;
170
- else if (a === "--uninstall") out.uninstall = true;
171
- else if (a === "--workspace") set("workspace", "a path");
172
- else if (a === "--scope") out.scope = true;
173
- else if (a === "--all") out.all = true; // legacy no-op (now default)
174
- else if (a === "--no-persist") out.noPersist = true;
175
- else if (a === "--history") set("history", "a path");
176
- else if (a === "--codex") out.codex = true;
177
- else if (a === "--no-codex") out.noCodex = true;
178
- else if (a === "--claude") out.claude = true;
179
- else if (a === "--no-claude") out.noClaude = true;
180
- else out.unknown.push(a);
181
- }
182
- return out;
183
- }
@@ -1,79 +0,0 @@
1
- // Updating the deck while nobody is looking at it.
2
- //
3
- // The update path had one automatic step, and it needed a page: the banner's
4
- // `auto when idle` restarted the deck once an install had landed, and only in a
5
- // tab somebody was looking at. An install itself never happened unless a person
6
- // pressed `Update now`, and a deck with no tab open stayed on its old code for
7
- // good. Since 3.20.0 the deck outlives its terminal and runs for days with no
8
- // tab at all, which made that the common case.
9
- //
10
- // So the server does it, at the one moment it cannot interrupt anybody: no tab
11
- // has focus (presence.mjs), no agent is mid-turn, and no turn has produced an
12
- // event for AWAY_QUIET_MS (activity.mjs). The same switch governs it — the
13
- // banner's `auto when idle`, kept in prefs.json as `autoUpdate` now so the
14
- // server can read it with no page open. A person looking at the deck still gets
15
- // the banner and its buttons; this is only for when they are not.
16
- //
17
- // Every effect goes through the code a press uses — startUpgrade, and the
18
- // launcher's restart — so an unattended update cannot do anything a person
19
- // could not have done from the banner. Pure rules here; index.mjs owns the
20
- // timer and the effects.
21
-
22
- /** Quiet before the deck may act: the page's IDLE_BEFORE_RESTART_MS, so the
23
- * two paths agree on what idle means. */
24
- export const AWAY_QUIET_MS = 30_000;
25
-
26
- /** How long a deck that has just started waits before acting at all. A
27
- * relaunch that came back on the version it left would otherwise try again a
28
- * minute later, and again after that; this caps any such loop at one attempt
29
- * per boot grace, under the launcher's own two-strike rule for npx
30
- * (supervisor.mjs, upgradeAttempt). */
31
- export const AWAY_BOOT_GRACE_MS = 5 * 60_000;
32
-
33
- /** How long the same attempt is left alone after it was made: an install that
34
- * failed, a fetch the launcher refused, a restart it could not run. A newer
35
- * version is a different attempt and is not held back by this. */
36
- export const AWAY_RETRY_MS = 30 * 60_000;
37
-
38
- /** How often the server asks. The gate below is answered from memory; only
39
- * when it passes does the tick read the version report, which touches disk. */
40
- export const AWAY_TICK_MS = 60_000;
41
-
42
- /** How long a report that found nothing newer is taken as the answer. The npm
43
- * lookup behind it is hourly anyway, and the report is not free: on Windows
44
- * upgradeBlock proves the npm prefix writable by creating a file in it, and a
45
- * deck left alone overnight would otherwise do that once a minute. */
46
- export const AWAY_RECHECK_MS = 5 * 60_000;
47
-
48
- /**
49
- * Whether the deck may consider updating itself right now. Every input is
50
- * already in memory, so this runs every tick at no cost.
51
- */
52
- export function awayGate({
53
- enabled, supervised, restarting, sinceBootMs, looking, busy, quietMs,
54
- graceMs = AWAY_BOOT_GRACE_MS, quietNeedMs = AWAY_QUIET_MS,
55
- }) {
56
- return enabled === true && supervised === true && !restarting
57
- && sinceBootMs >= graceMs && !looking && !busy && quietMs >= quietNeedMs;
58
- }
59
-
60
- /**
61
- * What to do about the version report, once the gate has passed.
62
- *
63
- * restart the newer code is already on disk (an install landed, or a
64
- * checkout was pulled) — the free half, as pickNotice says
65
- * install `npm i -g` over a global install; the next tick restarts into it
66
- * npx the launcher fetches the new version and hands it the port
67
- * null nothing newer, an install already running, the same attempt
68
- * made too recently, or a copy that only a person can update
69
- */
70
- export function awayUpdateStep({ notice, mode, installing, lastTry, now, retryMs = AWAY_RETRY_MS }) {
71
- if (!notice || installing) return { act: null };
72
- const target = `${notice.kind}:${notice.to}`;
73
- // A clock that moved backwards counts as time elapsed, as everywhere else in
74
- // the update path: a deck must not wait out a window it cannot measure.
75
- if (lastTry?.target === target && now >= lastTry.at && now - lastTry.at < retryMs) return { act: null, target };
76
- if (notice.kind === "restart") return { act: "restart", target };
77
- if (notice.kind === "upgrade" && (mode === "install" || mode === "npx")) return { act: mode, target };
78
- return { act: null, target };
79
- }
@@ -1,173 +0,0 @@
1
- // The half of "which agent is waiting on you" that no page can answer.
2
- //
3
- // The tab-side notifier (src/web/notify.ts) covers a deck that is open and
4
- // hidden — behind another window, on another desktop, in a tab you have not
5
- // looked at since lunch. It cannot cover the deck that is not open at all,
6
- // because raising a notification from a page requires a page, and the case this
7
- // whole feature exists for is the one where you walked away.
8
- //
9
- // The server can, and it already knows the one fact that makes it safe to:
10
- // `sseClients.size`. Nobody is listening means nobody is being told by any
11
- // other surface — the chip, the title, the favicon and the live region are all
12
- // drawn inside a document that does not exist right now — so a notification
13
- // here cannot duplicate one of them, and cannot arrive over a page the user is
14
- // looking at. The two notifiers are exclusive by construction rather than by
15
- // coordination, which is why neither has to know about the other.
16
- //
17
- // WHAT THIS DOES NOT DO IS DECIDE ANYTHING ABOUT AN AGENT. It reads one hook
18
- // event and raises a desktop notification. The hook script still exits 0
19
- // without writing to stdout, the deck still cannot allow, deny, defer or
20
- // rewrite a tool call, and `hook-read-only.test.ts` still holds that. The
21
- // distinction matters because "the deck notices your agent stopped" and "the
22
- // deck answers for your agent" are one keystroke apart in this codebase and
23
- // only one of them is a thing this product promises.
24
- //
25
- // Pure decisions here, the OS call injected, for the reason the web modules
26
- // give: what the suite cannot run is what drifts. `notify` comes from
27
- // browser-react.mjs, which already ships this on all three platforms for
28
- // Browser Watch — osascript with argv on macOS, a WinRT toast on Windows,
29
- // notify-send on Linux — so the platform work is done and tested and this is
30
- // the second caller rather than a second implementation.
31
- import { basename } from "node:path";
32
-
33
- /** Set `AGENTS_DECK_NO_NOTIFY=1` to keep the deck off the desktop entirely.
34
- * Same shape as AGENTS_DECK_NO_DOWNLOAD and AGENTS_DECK_NO_INSTALL, which is
35
- * the sheet of switches a user already knows to look for. */
36
- export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
37
-
38
- /**
39
- * How long one session stays quiet after it has been announced.
40
- *
41
- * The tab-side notifier keys its dedupe on `since`, which it can do because the
42
- * reducer refuses to re-stamp that field. Nothing here has a reducer: this sees
43
- * raw hook events, one at a time, and the same permission prompt can reach it
44
- * more than once — a hook retried, a deck replaying somebody else's log into
45
- * `POST /api/event`, or CC re-notifying about a prompt still standing.
46
- *
47
- * So the memo is a cooldown rather than an identity. Two minutes is chosen
48
- * against what it costs to be wrong in each direction: too short and one
49
- * unanswered prompt drums; too long and a genuinely new prompt on a busy
50
- * session is swallowed. A prompt answered inside two minutes did not need the
51
- * notification, and one that is still standing after two minutes is worth
52
- * saying again to somebody who is, by construction, not looking at a screen
53
- * that says it.
54
- */
55
- export const QUIET_MS = 2 * 60 * 1000;
56
-
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) {
78
- return !!raw
79
- && raw.hook_event_name === "Notification"
80
- && (raw.notification_type === "permission_prompt"
81
- || raw.notification_type === "agent_needs_input");
82
- }
83
-
84
- /**
85
- * What to put on the desktop.
86
- *
87
- * The title is the working directory's last segment, because that is what the
88
- * user calls the thing — "vcrm-core", not a UUID — and because a notification
89
- * title is the only line no platform truncates. The deck's own name goes in it
90
- * too: this arrives with no window and no tab beside it to say where it came
91
- * from, which is the one context the in-page notifier never has to supply.
92
- *
93
- * The body is CC's sentence, verbatim and alone. The tool guess that the tab
94
- * shows is deliberately absent: it is inferred by the REDUCER from the newest
95
- * call still in flight, and nothing on this side of the wire tracks in-flight
96
- * calls. Rebuilding that here to fill a notification body would be a second,
97
- * dimmer copy of a rule that already exists — the failure ambient-counts.ts and
98
- * block-announce.ts were both written to end.
99
- */
100
- export function blockNotice(raw, product) {
101
- const cwd = typeof raw.cwd === "string" && raw.cwd ? basename(raw.cwd) : "";
102
- const who = cwd || (typeof raw.session_id === "string" ? raw.session_id.slice(0, 8) : "a session");
103
- const said = typeof raw.message === "string" && raw.message ? raw.message : "Needs your permission";
104
- return { title: `${who} — ${product}`, body: said };
105
- }
106
-
107
- /**
108
- * Should this event put something on the desktop?
109
- *
110
- * Four gates, and three of them are about a burst rather than a single
111
- * notification — a channel that fires twelve times in a second is one the user
112
- * turns off within the minute:
113
- *
114
- * - a blocking prompt, per `isBlockingPrompt`
115
- * - NOTHING LISTENING. A page is a better surface than this in every way, so
116
- * wherever there is one, this stays out of the way.
117
- * - NOT A REPLAY. The server replays events.jsonl into itself at boot to
118
- * rebuild the ring, and that log holds every permission prompt of the last
119
- * 50MB. Without this gate, starting the deck would announce the entire
120
- * history of the machine at once.
121
- * - the session has not just been announced, per `QUIET_MS`.
122
- */
123
- export function shouldNotify(raw, { clients, replay, lastAt, now }) {
124
- if (!isBlockingPrompt(raw)) return false;
125
- if (clients > 0) return false;
126
- if (replay) return false;
127
- if (lastAt != null && now - lastAt < QUIET_MS) return false;
128
- return true;
129
- }
130
-
131
- /**
132
- * The stateful wrapper index.mjs holds: the per-session memo, and the call out
133
- * to the OS.
134
- *
135
- * `notify` and `now` are injected so the suite drives this without a desktop
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.
146
- */
147
- export function createBlockNotifier({ notify, product, now = Date.now, enabled = true, onError }) {
148
- const isEnabled = typeof enabled === "function" ? enabled : () => enabled;
149
- /** session_id → when it was last announced. Bounded by pruning on read: a
150
- * long-lived server sees many sessions and this must not become a second
151
- * ring nobody empties. */
152
- const seen = new Map();
153
-
154
- return {
155
- /** Returns what it did, for the tests and for nothing else. */
156
- consider(raw, { clients, replay = false }) {
157
- if (!isEnabled()) return "off";
158
- const at = now();
159
- const id = raw?.session_id ?? "";
160
- if (!shouldNotify(raw, { clients, replay, lastAt: seen.get(id), now: at })) return "skipped";
161
- seen.set(id, at);
162
- for (const [key, when] of seen) if (at - when > QUIET_MS) seen.delete(key);
163
- const { title, body } = blockNotice(raw, product);
164
- // Fire-and-forget, and the catch is not decoration. `notify` shells out —
165
- // osascript, PowerShell, notify-send — and on a Linux box with no
166
- // notification daemon the last of those simply is not there. A rejected
167
- // promise from a notification must never take down the ingest path that
168
- // every hook event in the process goes through.
169
- Promise.resolve(notify(title, body)).catch(err => onError?.(err));
170
- return "notified";
171
- },
172
- };
173
- }
@@ -1,127 +0,0 @@
1
- // The boot has a deadline, and the jobs under it do not get to decide it.
2
- //
3
- // #742. Two people on two operating systems reported the same thing: `npx
4
- // ccdeck`, the wordmark, four rows, then a spinner at "checking claude-swap…"
5
- // and nothing — no "server ready", no browser, no way to tell a slow machine
6
- // from a dead one. The port was open the whole time. What they were watching
7
- // was `reportStartup` awaiting `ensureCswap`, which on a machine that has
8
- // neither claude-swap nor a Python toolchain downloads a uv binary under a
9
- // 120-second deadline and then runs `uv tool install claude-swap` under a
10
- // 180-second one. Five minutes, worst case, before the line that says where to
11
- // point a browser — for a panel that is optional and that nobody asked for
12
- // during that boot.
13
- //
14
- // The install itself is not the bug and is not cancelled here. What was wrong
15
- // is that the boot waited for it. So every job whose failure is not fatal is
16
- // given a slice of the boot rather than the whole of it, and a job that is
17
- // still working when its slice runs out is SAID SO and left running. The row it
18
- // would have printed is printed later, when it settles, which is the same
19
- // contract the deck already has with its background upgrade.
20
- //
21
- // bin/deck.js's `update` job has raced a timer since it was written — this is
22
- // that idea, given a name and the one thing the inline race could not do: tell
23
- // "the job answered null" apart from "the job did not answer", which for
24
- // claude-swap is the difference between "not installed" and "still installing".
25
-
26
- /** How long the whole report may spend waiting on jobs that are not fatal.
27
- *
28
- * Eight seconds is chosen from the two ends it sits between. Below it are the
29
- * probes a normal boot really does pay — `cswap --version`, `uv --version`,
30
- * a PyPI lookup — which finish in well under a second on a warm machine and in
31
- * two or three on a cold one behind a slow DNS; a deadline under that would
32
- * turn every honest boot into a background one and the rows would stop being
33
- * where a reader looks for them. Above it is the only thing the deadline
34
- * exists to bound, and that one is measured in minutes, so there is no value
35
- * in the middle that anybody would notice being wrong. */
36
- export const BOOT_DEADLINE_MS = 8_000;
37
-
38
- /**
39
- * The deadline, as the environment may override it.
40
- *
41
- * Tests need a boot they can watch inside a test budget, and a test that has to
42
- * wait eight real seconds to prove the deadline works is a test that costs the
43
- * suite eight seconds forever. Read from the environment rather than passed
44
- * down through four call sites for one caller that is not the product.
45
- *
46
- * Anything unparseable, negative, or absent is the default. Zero is honoured —
47
- * "give the jobs nothing" is a coherent thing for a test to ask for, and the
48
- * timer path below still runs, so it exercises the same code the product does.
49
- */
50
- export function bootDeadlineMs(env = process.env) {
51
- const raw = env.AGENTS_DECK_BOOT_DEADLINE_MS;
52
- if (raw === undefined || raw === "") return BOOT_DEADLINE_MS;
53
- const n = Number(raw);
54
- return Number.isFinite(n) && n >= 0 ? n : BOOT_DEADLINE_MS;
55
- }
56
-
57
- /**
58
- * Await `work`, but never past `ms`.
59
- *
60
- * Answers `{ done: true, value }` or `{ done: false, value: undefined }`. A
61
- * shape rather than a sentinel value, because three of the jobs this wraps
62
- * resolve to `null` on purpose — "not attempted" is a real answer and must not
63
- * read as "ran out of time".
64
- *
65
- * A rejection is a settled job, not a timeout: `{ done: true, value: undefined
66
- * }`. Every caller here already attached its own rejection handler at the point
67
- * the promise was created, so the throw has been dealt with and what is left to
68
- * decide is only whether to keep waiting. Re-throwing would turn a job that
69
- * failed politely into a boot that died.
70
- *
71
- * The timer is cleared the moment the job settles, so a boot whose jobs all
72
- * answer at once does not hold the event loop for the rest of the deadline.
73
- *
74
- * It is deliberately NOT unref'd. An unref'd deadline is a deadline that does
75
- * not fire when the only thing left in the process is the deadline itself —
76
- * which is exactly the shape of a test that awaits nothing else, and would have
77
- * made this function pass by exiting rather than by working. The cost of
78
- * keeping it is at most one deadline's worth of a process that was about to
79
- * end, against a deck that runs until Ctrl+C.
80
- */
81
- export function within(work, ms, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
82
- return new Promise(resolve => {
83
- let answered = false;
84
- const timer = setTimer(() => {
85
- if (answered) return;
86
- answered = true;
87
- resolve({ done: false, value: undefined });
88
- }, ms);
89
- Promise.resolve(work).then(
90
- value => {
91
- if (answered) return;
92
- answered = true;
93
- clearTimer(timer);
94
- resolve({ done: true, value });
95
- },
96
- () => {
97
- if (answered) return;
98
- answered = true;
99
- clearTimer(timer);
100
- resolve({ done: true, value: undefined });
101
- },
102
- );
103
- });
104
- }
105
-
106
- /**
107
- * A deadline shared by everything that draws on it, in the order they are
108
- * awaited.
109
- *
110
- * One budget for the whole report rather than one each, because the rows are
111
- * awaited in sequence and per-job deadlines would multiply: four jobs at eight
112
- * seconds is a thirty-two second boot that every individual deadline would call
113
- * within its budget. What a reader was promised is that the deck is ready
114
- * within the deadline, and that promise is about the sum.
115
- *
116
- * `left()` never goes below zero, so a job reached after the budget is spent is
117
- * given a zero-length slice and reported as still working — which it is.
118
- */
119
- export function budget(ms, now = Date.now) {
120
- const started = now();
121
- return {
122
- left: () => Math.max(0, ms - (now() - started)),
123
- spent: () => now() - started,
124
- /** Await `work` against whatever is left. */
125
- within: (work, deps) => within(work, Math.max(0, ms - (now() - started)), deps),
126
- };
127
- }
@@ -1,16 +0,0 @@
1
- // The name the server prints, in one place — the console half of what
2
- // src/web/brand.ts holds for the browser and src/server/term.mjs draws as the
3
- // banner wordmark. Every `PRODUCT:` prefix below is a line a user reads when
4
- // something has already gone wrong, which is the worst moment to introduce
5
- // yourself by a name that appears nowhere else they have seen.
6
- //
7
- // Display name only. The package published to npm is still `agents-deck`, so
8
- // self-update.mjs's `name` defaults stay as they are — that string is what the
9
- // registry is queried for and what `npm i -g` installs, and an install that
10
- // names the product instead of the package installs nothing. Same for the
11
- // marker files under ~/.agents-deck, the event log under ~/.claude/agent-dag,
12
- // the AGENTS_DECK_* variables and the `user-agent` headers: identifiers outlive
13
- // a rename, and moving one orphans a running deck from its own state.
14
-
15
- /** The prefix on every line the deck writes to a console or a log. */
16
- export const PRODUCT = "ccdeck";