agent-dag 3.22.1 → 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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/args.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/server/brand.mjs
DELETED
|
@@ -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";
|