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/dist/web/index.html
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>ccdeck</title>
|
|
7
|
-
<!-- The resting pair, and only the resting pair. Both are parsed before any
|
|
8
|
-
module runs, so this is the deck at boot — nothing waiting, nothing
|
|
9
|
-
known to be running — and App.tsx's ambient effect corrects both within
|
|
10
|
-
the first frame it has state for. The title is the one copy of the name
|
|
11
|
-
that no constant can reach (display-name.test.ts pins it to PRODUCT);
|
|
12
|
-
the href is ambient.ts's own idle mark, generated by FAVICON_HREF and
|
|
13
|
-
pinned to it by ambient-signal.test.ts, which is why it is an
|
|
14
|
-
unreadable percent-encoded run rather than something to hand-edit. It
|
|
15
|
-
was a `<text>◉</text>` glyph until #338 — a font-dependent shape that
|
|
16
|
-
renders at three different weights on the three platforms and as tofu
|
|
17
|
-
where the codepoint is missing, which stopped being acceptable the
|
|
18
|
-
moment the icon started carrying state. -->
|
|
19
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Ccircle%20cx%3D%2216%22%20cy%3D%2216%22%20r%3D%2212%22%20fill%3D%22none%22%20stroke%3D%22%237e828c%22%20stroke-width%3D%225%22%2F%3E%3C%2Fsvg%3E" />
|
|
20
|
-
<!-- The theme, before the first frame rather than after it.
|
|
21
|
-
styles.css keys every colour off :root[data-theme=…] and reads a
|
|
22
|
-
missing attribute as dark, so a light-theme user whose preference is
|
|
23
|
-
applied by a React effect watches a fully painted dark deck first —
|
|
24
|
-
native scrollbars and form controls included, because that block also
|
|
25
|
-
declares color-scheme: dark. The bundle cannot fix this: it is a module
|
|
26
|
-
script, so it is deferred and the paint can happen while it is still
|
|
27
|
-
being fetched. This is a classic inline script, which means the parser
|
|
28
|
-
stops here, inside <head>, before any frame exists — and it is what
|
|
29
|
-
Vite leaves untouched in the built HTML (it rewrites only scripts with
|
|
30
|
-
a src or type="module"). Deliberately dependency-free, and kept honest
|
|
31
|
-
against resolveTheme() in theme.ts by theme-first-paint.test.ts, which
|
|
32
|
-
runs this exact text. -->
|
|
33
|
-
<script>
|
|
34
|
-
(function () {
|
|
35
|
-
var stored = null;
|
|
36
|
-
// `window.localStorage` is a GETTER that throws where site data is
|
|
37
|
-
// blocked, so the property read is inside the try, not just getItem.
|
|
38
|
-
// A refused store must cost a preference and never the boot.
|
|
39
|
-
try { stored = window.localStorage.getItem("agent-dag.theme"); } catch (e) {}
|
|
40
|
-
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
|
-
})();
|
|
42
|
-
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-Ifm23DDC.js"></script>
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-CJYsv0lr.css">
|
|
45
|
-
</head>
|
|
46
|
-
<body>
|
|
47
|
-
<div id="root"></div>
|
|
48
|
-
</body>
|
|
49
|
-
</html>
|
package/hook/hook.js
DELETED
|
@@ -1,542 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// agent-dag hook forwarder. Invoked by Claude Code or Codex CLI as a command
|
|
3
|
-
// hook. Reads stdin (event JSON), tags it with the provider passed via
|
|
4
|
-
// `--provider <name>`, finds every agent-dag server whose workspace contains the
|
|
5
|
-
// session — via the discovery files in <claude config dir>/agent-dag/ — makes
|
|
6
|
-
// each one prove it is the deck its file describes, and POSTs the payload. Dead
|
|
7
|
-
// instances are cleaned up.
|
|
8
|
-
"use strict";
|
|
9
|
-
|
|
10
|
-
const fs = require("fs");
|
|
11
|
-
const path = require("path");
|
|
12
|
-
const http = require("http");
|
|
13
|
-
const os = require("os");
|
|
14
|
-
const crypto = require("crypto");
|
|
15
|
-
|
|
16
|
-
// Single shared discovery dir — Claude Code and Codex CLI both register here
|
|
17
|
-
// via the installer. Lets one running agent-dag server receive both providers.
|
|
18
|
-
//
|
|
19
|
-
// This has to name the same directory src/server/claude-dir.mjs does, because
|
|
20
|
-
// the installer writes the files read below. It is duplicated rather than
|
|
21
|
-
// imported because this script is copied out of the package and run standalone
|
|
22
|
-
// by the host CLI, with no path back to the module it came from.
|
|
23
|
-
const configOverride = (process.env.CLAUDE_CONFIG_DIR || "").trim();
|
|
24
|
-
const CLAUDE_DIR = configOverride
|
|
25
|
-
? path.resolve(configOverride)
|
|
26
|
-
: path.join(os.homedir(), ".claude");
|
|
27
|
-
const DIR = path.join(CLAUDE_DIR, "agent-dag");
|
|
28
|
-
|
|
29
|
-
function parseProvider(argv) {
|
|
30
|
-
for (let i = 0; i < argv.length; i++) {
|
|
31
|
-
if (argv[i] === "--provider" && i + 1 < argv.length) return argv[i + 1];
|
|
32
|
-
}
|
|
33
|
-
return "claude";
|
|
34
|
-
}
|
|
35
|
-
const PROVIDER = parseProvider(process.argv.slice(2));
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The one spelling of a directory, so that a path this process reports and a
|
|
39
|
-
* path bin/deck.js published can be compared as strings.
|
|
40
|
-
*
|
|
41
|
-
* Resolving symlinks is the half that is easy to think you can skip, because on
|
|
42
|
-
* POSIX a cwd comes from getcwd(3) and has none left in it. Windows has no such
|
|
43
|
-
* guarantee — GetCurrentDirectoryW returns the string the directory was set
|
|
44
|
-
* with, junction, `subst` drive and all — so a workspace reached that way only
|
|
45
|
-
* matches if BOTH sides go through here. The server's rollout watcher keeps its
|
|
46
|
-
* own copy of this rule under the name canonicalCwd, for the Codex sessions that
|
|
47
|
-
* never reach this file; a test walks one path through both. A path that does
|
|
48
|
-
* not resolve keeps its resolved form, which is also what canonicalWorkspace
|
|
49
|
-
* does with a directory the user has not created yet.
|
|
50
|
-
*
|
|
51
|
-
* `.native` IS THE RULE, not a detail. fs.realpathSync is a JavaScript
|
|
52
|
-
* lstat-and-readlink walk that resolves symlinks and junctions and nothing else;
|
|
53
|
-
* fs.realpathSync.native is GetFinalPathNameByHandleW, which also expands a DOS
|
|
54
|
-
* 8.3 short component to its long form. This used to call the plain one while
|
|
55
|
-
* the server's canonicalCwd went through the native one, so the moment a path
|
|
56
|
-
* arrived short — `%TEMP%` under a shortened profile directory, which is what
|
|
57
|
-
* every GitHub Windows runner has — the two canonicalisers that exist to agree
|
|
58
|
-
* disagreed by a whole path: C:\Users\RUNNER~1\… against C:\Users\runneradmin\….
|
|
59
|
-
* canonicalWorkspace in src/server/index.mjs says the rest of it, including why
|
|
60
|
-
* the long form is the canonical one; all three sites name `.native` out loud.
|
|
61
|
-
*
|
|
62
|
-
* Exported for that test: it is half of what `--workspace` means, and a
|
|
63
|
-
* predicate handed an already-canonical path cannot show that the caller
|
|
64
|
-
* canonicalises.
|
|
65
|
-
*/
|
|
66
|
-
function normPath(p) {
|
|
67
|
-
let r = path.resolve(p);
|
|
68
|
-
try { r = fs.realpathSync.native(r); } catch {}
|
|
69
|
-
return r;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Does this platform's filesystem treat two spellings that differ only in case
|
|
74
|
-
* as the same directory? Exported for tests: the platform is a parameter so
|
|
75
|
-
* both answers can be checked from either kind of machine.
|
|
76
|
-
*
|
|
77
|
-
* Windows always does, and macOS does by default (APFS and HFS+ are formatted
|
|
78
|
-
* case-insensitive unless the user deliberately chose otherwise). Linux does
|
|
79
|
-
* not, and folding case there would be a bug of its own: /srv/Proj and
|
|
80
|
-
* /srv/proj are two real directories, and a deck scoped to one must not be
|
|
81
|
-
* handed the other's events.
|
|
82
|
-
*
|
|
83
|
-
* A case-sensitive macOS volume is therefore over-matched. That is the safe
|
|
84
|
-
* direction to be wrong in — the cost is a deck that also sees a sibling tree
|
|
85
|
-
* it was not scoped to, against the cost of the default configuration seeing
|
|
86
|
-
* nothing at all.
|
|
87
|
-
*/
|
|
88
|
-
const foldsCase = (platform = process.platform) =>
|
|
89
|
-
platform === "win32" || platform === "darwin";
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Is `cwd` the workspace directory or somewhere inside it?
|
|
93
|
-
*
|
|
94
|
-
* Both sides arrive already resolved, but resolved is not the same as
|
|
95
|
-
* comparable. Neither path.resolve nor the JS fs.realpathSync canonicalizes
|
|
96
|
-
* character case, so the drive letter and every component keep whatever case
|
|
97
|
-
* the process that reported them happened to use — `c:\proj` from one shell,
|
|
98
|
-
* `C:\Proj` from another, for one directory. A raw === / startsWith then says
|
|
99
|
-
* "not in the workspace", the hook posts to nobody, and a scoped deck stays
|
|
100
|
-
* empty with no error printed anywhere. Re-resolving through the platform's
|
|
101
|
-
* own path flavour also settles separators and a trailing one, so
|
|
102
|
-
* `C:/proj/` and `C:\proj` compare equal too.
|
|
103
|
-
*
|
|
104
|
-
* The platform is a parameter, following spawnSpec/isBatch in
|
|
105
|
-
* src/server/exec.mjs, so the Windows rule is testable from a POSIX machine.
|
|
106
|
-
*/
|
|
107
|
-
function cwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
108
|
-
const p = platform === "win32" ? path.win32 : path.posix;
|
|
109
|
-
const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
|
|
110
|
-
const a = fold(p.resolve(cwd));
|
|
111
|
-
const b = fold(p.resolve(workspace));
|
|
112
|
-
if (a === b) return true;
|
|
113
|
-
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
114
|
-
// would match nothing.
|
|
115
|
-
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Does a deck scoped to `workspace` capture a session running in `cwd`? This is
|
|
120
|
-
* the whole of what `--workspace` means, and it is a question about ONE deck: it
|
|
121
|
-
* asks nothing about the others that may also be up, so a deck's answer never
|
|
122
|
-
* depends on who else is running.
|
|
123
|
-
*
|
|
124
|
-
* An empty workspace is the default — machine-wide — and captures everything.
|
|
125
|
-
* It is answered before cwdInWorkspace rather than passed to it because
|
|
126
|
-
* p.resolve("") is the resolving process's own cwd, which here is the agent's,
|
|
127
|
-
* so an unscoped deck would be silently scoped to whatever directory the user
|
|
128
|
-
* happened to run their agent in.
|
|
129
|
-
*
|
|
130
|
-
* A session that never said where it runs is inside no workspace, so only an
|
|
131
|
-
* unscoped deck sees it. Unreachable from main(), which exits before this on a
|
|
132
|
-
* payload with no cwd — it is here because the rule has to be stated the same
|
|
133
|
-
* way on both sides to be pinned against the other one.
|
|
134
|
-
*
|
|
135
|
-
* src/server/log-writer.mjs answers this same question, for the sessions the
|
|
136
|
-
* server builds itself out of Codex's rollout files, under the name
|
|
137
|
-
* codexCwdInWorkspace — this script is copied out of the package and run
|
|
138
|
-
* standalone, so it cannot import that copy. A test walks one table of paths
|
|
139
|
-
* through both: a disagreement between them is `--workspace` meaning two
|
|
140
|
-
* different things depending on which CLI produced the session.
|
|
141
|
-
*/
|
|
142
|
-
function capturesSession(cwd, workspace, platform = process.platform) {
|
|
143
|
-
if (!workspace || typeof workspace !== "string") return true;
|
|
144
|
-
if (!cwd || typeof cwd !== "string") return false;
|
|
145
|
-
return cwdInWorkspace(cwd, workspace, platform);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
149
|
-
//
|
|
150
|
-
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
151
|
-
// answers EPERM for a process this account may not signal. On Windows
|
|
152
|
-
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
153
|
-
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
154
|
-
// another account, read as DEAD to every probe in this repo. What followed was
|
|
155
|
-
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
156
|
-
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
157
|
-
// claiming it was receiving events it had stopped receiving.
|
|
158
|
-
function isAlive(pid) {
|
|
159
|
-
try { process.kill(pid, 0); return true; }
|
|
160
|
-
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Of the decks about to be posted this event, which ones should also write it
|
|
165
|
-
* to disk? Returns the subset that should; every other target is asked to
|
|
166
|
-
* display the event and keep no record of it.
|
|
167
|
-
*
|
|
168
|
-
* The fan-out itself is deliberate — several decks can match one session and
|
|
169
|
-
* they should all draw it. Persisting is not: they all default to the same
|
|
170
|
-
* <claude config dir>/agent-dag/events.jsonl, so each of them appending its own
|
|
171
|
-
* copy wrote every event once per running deck. The file then grew N times as
|
|
172
|
-
* fast, rotated N times as often, and every replay of it ingested each tool
|
|
173
|
-
* call N times, which is what put duplicate tools and duplicate bubbles on the
|
|
174
|
-
* canvas after a restart.
|
|
175
|
-
*
|
|
176
|
-
* Decks are therefore grouped by the log file each one names in its discovery
|
|
177
|
-
* record, and one deck per group is elected. Grouping by the file rather than
|
|
178
|
-
* counting decks is what keeps the overrides honest: a deck run with
|
|
179
|
-
* `--history` sits alone in its own group and always writes, a deck run with
|
|
180
|
-
* `--no-persist` reports no file and can never be elected to write for one that
|
|
181
|
-
* does, and a deck too old to report either keeps the behaviour it had before
|
|
182
|
-
* this rule existed. Within a group the lowest port wins — a fixed rule, so the
|
|
183
|
-
* same deck holds the file for as long as it is up and the next one inherits it
|
|
184
|
-
* as soon as that deck is gone.
|
|
185
|
-
*
|
|
186
|
-
* The platform is a parameter, like cwdInWorkspace's, so the case-folding half
|
|
187
|
-
* is testable from any machine.
|
|
188
|
-
*
|
|
189
|
-
* src/server/log-writer.mjs repeats this rule for the events no hook delivers —
|
|
190
|
-
* the ones the server builds itself from Codex's rollout files — because this
|
|
191
|
-
* script is copied out of the package and cannot import it. A test compares the
|
|
192
|
-
* two directly: they decide for the same decks, and a disagreement is a line
|
|
193
|
-
* written twice or not at all.
|
|
194
|
-
*/
|
|
195
|
-
function electWriters(decks, platform = process.platform) {
|
|
196
|
-
const byLog = new Map();
|
|
197
|
-
for (const d of decks) {
|
|
198
|
-
const log = typeof d.persist === "string" ? d.persist : "";
|
|
199
|
-
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
200
|
-
// report one — is alone in its group and cannot collide with a real path.
|
|
201
|
-
const key = log
|
|
202
|
-
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
203
|
-
: `deck:${d.pid}:${d.port}`;
|
|
204
|
-
const held = byLog.get(key);
|
|
205
|
-
// Ports are unique among live decks; pid only breaks a tie a stale
|
|
206
|
-
// discovery file could invent, so the answer stays deterministic.
|
|
207
|
-
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
208
|
-
byLog.set(key, d);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return new Set(byLog.values());
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* The answer a deck must give to be handed a session payload.
|
|
216
|
-
*
|
|
217
|
-
* Liveness of the recorded pid is not evidence that the thing listening on the
|
|
218
|
-
* recorded port is a deck. A deck killed with SIGKILL or lost to a power cut
|
|
219
|
-
* leaves its discovery file behind — nothing unlinks it — and every cleanup
|
|
220
|
-
* path here and in the server probes the same pid. Once the OS hands that
|
|
221
|
-
* number to some other long-lived process the file passes forever, and the
|
|
222
|
-
* port it names may by then belong to anything at all (4317, the deck's own
|
|
223
|
-
* default, is also the standard OTLP collector port). What was POSTed there is
|
|
224
|
-
* the whole hook event: prompt text, tool inputs, tool results, cwd.
|
|
225
|
-
*
|
|
226
|
-
* So the port has to prove itself before it is told anything. The deck writes a
|
|
227
|
-
* fresh random token into its discovery file at startup; this hook asks the
|
|
228
|
-
* listener to hash that token against a nonce it has never seen, and sends the
|
|
229
|
-
* payload only if the answer matches. A stranger on the port cannot answer
|
|
230
|
-
* without the token, and the nonce is new every time, so an answer overheard
|
|
231
|
-
* earlier is worth nothing. Note the direction: the hook never transmits the
|
|
232
|
-
* token itself, only a challenge, so a wrong listener learns nothing it could
|
|
233
|
-
* replay against the next event.
|
|
234
|
-
*
|
|
235
|
-
* Both sides must derive the proof identically — src/server/index.mjs exports
|
|
236
|
-
* the same function under the same name, and the pair is pinned by a test.
|
|
237
|
-
*/
|
|
238
|
-
function challengeProof(token, nonce) {
|
|
239
|
-
return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Must this target answer the challenge before it is handed a payload?
|
|
244
|
-
*
|
|
245
|
-
* Only a deck that advertises a token can be asked to prove it holds one. And
|
|
246
|
-
* hook.js is a single shared file — <claude config dir>/agent-dag/hook.js,
|
|
247
|
-
* installed by whichever deck booted most recently — while running several
|
|
248
|
-
* decks at once is ordinary use. So a hook that knows about the handshake
|
|
249
|
-
* routinely reads discovery files written by decks that predate it, which serve
|
|
250
|
-
* no /api/hook-challenge route at all. Refusing those outright leaves every one
|
|
251
|
-
* of them listening and permanently empty, with its banner still saying it is
|
|
252
|
-
* receiving events.
|
|
253
|
-
*
|
|
254
|
-
* THE FALLBACK IS GONE, on the condition this comment set for itself: "drop it
|
|
255
|
-
* once no deck older than 1.33.71 is plausibly still running". That release is
|
|
256
|
-
* two majors back — this package is on 3.x — so the window has closed.
|
|
257
|
-
*
|
|
258
|
-
* What it did while it stood: a tokenless discovery file was handed the payload
|
|
259
|
-
* on pid liveness alone, which is a control an adversary switches off by
|
|
260
|
-
* leaving a key out of a JSON file. It cost that adversary nothing to write
|
|
261
|
-
* one, since writing into the discovery directory at all is the capability in
|
|
262
|
-
* question — but a stale file from an old deck, or a port another program has
|
|
263
|
-
* since taken, is the ordinary case it also covered, and both are better served
|
|
264
|
-
* by refusing.
|
|
265
|
-
*
|
|
266
|
-
* The cost of refusing is stated plainly: a deck older than 1.33.71 running
|
|
267
|
-
* beside a current one receives nothing, while its banner still says it is
|
|
268
|
-
* connected. That was the reason to keep the fallback in the first place, and
|
|
269
|
-
* it is now a machine nobody has.
|
|
270
|
-
*/
|
|
271
|
-
function requiresProof(d) {
|
|
272
|
-
return true;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Constant-time compare, purely so a hostile listener cannot walk the expected
|
|
276
|
-
// proof out of us one byte at a time by timing how long we take to hang up. The
|
|
277
|
-
// lengths are public (64 hex chars) and a mismatched one is rejected outright,
|
|
278
|
-
// which is what timingSafeEqual requires of its arguments anyway.
|
|
279
|
-
function sameProof(got, want) {
|
|
280
|
-
if (typeof got !== "string") return false;
|
|
281
|
-
const a = Buffer.from(got, "utf8");
|
|
282
|
-
const b = Buffer.from(want, "utf8");
|
|
283
|
-
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Two round trips happen per target, and main()'s hard cap is 1500ms, so the
|
|
287
|
-
// pair has to fit inside it with room to spare. The challenge is a bodyless GET
|
|
288
|
-
// to a loopback port — sub-millisecond when a deck is there, and instant
|
|
289
|
-
// ECONNREFUSED when nothing is.
|
|
290
|
-
//
|
|
291
|
-
// They are now separated by a barrier: every target is challenged, then the
|
|
292
|
-
// election is decided, then the payload goes out (#695). The worst case is
|
|
293
|
-
// unchanged — the challenges run in parallel, so it is still one 400ms deadline
|
|
294
|
-
// followed by one 1000ms deadline. What the barrier does cost is that an honest
|
|
295
|
-
// deck's POST waits for the slowest challenge in the set, which only matters
|
|
296
|
-
// when some OTHER record's port accepts a connection and then says nothing. A
|
|
297
|
-
// ghost port with nothing behind it refuses instantly and delays no one.
|
|
298
|
-
const CHALLENGE_TIMEOUT_MS = 400;
|
|
299
|
-
const POST_TIMEOUT_MS = 1000;
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Ask the listener to prove it is the deck that wrote `d`. `cb` is called
|
|
303
|
-
* exactly once with true or false — a refused connection, a silent port and a
|
|
304
|
-
* wrong answer are all just "not the deck this record describes".
|
|
305
|
-
*
|
|
306
|
-
* A deck that advertised no token cannot be asked and passes: see requiresProof.
|
|
307
|
-
*/
|
|
308
|
-
function prove(d, cb, attempt = 0) {
|
|
309
|
-
let settled = false;
|
|
310
|
-
// A DEADLINE IS NOT AN ANSWER, and the difference is worth one retry.
|
|
311
|
-
//
|
|
312
|
-
// A wrong proof, a refused connection and a 404 are all verdicts: that port
|
|
313
|
-
// is not the deck this record describes, and asking again would get the same
|
|
314
|
-
// answer. A TIMEOUT is not — it is a machine too busy to reply in 400ms, and
|
|
315
|
-
// the deck on the other side is fine. Measured on the Windows box: the full
|
|
316
|
-
// test suite (335 files in parallel) is enough load to make a healthy deck
|
|
317
|
-
// miss that window, and the event is then dropped with nothing on screen to
|
|
318
|
-
// say so. A big build or a machine running several agents is the same shape.
|
|
319
|
-
//
|
|
320
|
-
// One retry, only on the deadline, and the budget still fits: 400 + 400 for
|
|
321
|
-
// the challenge and 1000 for the POST, under the 1900ms cap main() sets —
|
|
322
|
-
// which is itself under the two-second timeout the installed hook entry
|
|
323
|
-
// carries, so Claude Code never has to kill this process.
|
|
324
|
-
const retryOnTimeout = () => {
|
|
325
|
-
if (settled) return;
|
|
326
|
-
if (attempt >= 1) return finish(false);
|
|
327
|
-
settled = true; // this attempt is over; the next owns `cb`
|
|
328
|
-
prove(d, cb, attempt + 1);
|
|
329
|
-
};
|
|
330
|
-
const finish = ok => { if (settled) return; settled = true; cb(ok); };
|
|
331
|
-
|
|
332
|
-
if (!requiresProof(d)) return finish(true);
|
|
333
|
-
|
|
334
|
-
const nonce = crypto.randomBytes(16).toString("hex");
|
|
335
|
-
const want = challengeProof(d.token, nonce);
|
|
336
|
-
|
|
337
|
-
const req = http.request({
|
|
338
|
-
hostname: "127.0.0.1",
|
|
339
|
-
port: d.port,
|
|
340
|
-
path: `/api/hook-challenge?nonce=${nonce}`,
|
|
341
|
-
method: "GET",
|
|
342
|
-
timeout: CHALLENGE_TIMEOUT_MS,
|
|
343
|
-
}, res => {
|
|
344
|
-
if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
|
|
345
|
-
let answer = "";
|
|
346
|
-
res.setEncoding("utf8");
|
|
347
|
-
res.on("data", c => {
|
|
348
|
-
answer += c;
|
|
349
|
-
// A deck answers in ~100 bytes. Anything pouring data at us is not one,
|
|
350
|
-
// and must not be allowed to grow this buffer without bound.
|
|
351
|
-
if (answer.length > 4096) { req.destroy(); finish(false); }
|
|
352
|
-
});
|
|
353
|
-
res.on("end", () => {
|
|
354
|
-
// Already given up on this target — a flood we cut off above. Whatever
|
|
355
|
-
// arrived before that is not an answer we are going to act on.
|
|
356
|
-
if (settled) return;
|
|
357
|
-
let proof;
|
|
358
|
-
try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
|
|
359
|
-
finish(sameProof(proof, want));
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
// `destroy()` on a timeout makes 'error' fire with ECONNRESET, so the two
|
|
363
|
-
// handlers have to agree on which of them is speaking: `timedOut` is what
|
|
364
|
-
// tells a deadline apart from a refusal.
|
|
365
|
-
let timedOut = false;
|
|
366
|
-
req.on("error", () => { if (timedOut) retryOnTimeout(); else finish(false); });
|
|
367
|
-
req.on("timeout", () => { timedOut = true; req.destroy(); });
|
|
368
|
-
req.end();
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Challenge every target, then hand back the ones that answered — in the order
|
|
373
|
-
* they were given, so the election below is a function of the records alone.
|
|
374
|
-
*
|
|
375
|
-
* WHY THIS RUNS BEFORE THE ELECTION AND NOT AFTER IT (#695). The two round trips
|
|
376
|
-
* per target have always both happened; they used to happen in the wrong order.
|
|
377
|
-
* electWriters ran over every record whose pid was merely alive, and only then
|
|
378
|
-
* did deliver() challenge each target and drop the ones that could not answer.
|
|
379
|
-
* So a record left behind by a deck that is gone — SIGKILL, an OOM kill, a power
|
|
380
|
-
* cut, a console window closed on Windows, none of which run the shutdown that
|
|
381
|
-
* unlinks it — kept passing the one staleness test there is the moment the OS
|
|
382
|
-
* handed its pid to some other long-lived process. If it also named a port below
|
|
383
|
-
* every real deck's, it WON the election, was never posted to because it could
|
|
384
|
-
* not answer, and no other deck was posted to with the flag either: every deck
|
|
385
|
-
* drew the event, all of them were told `?persist=0`, and events.jsonl stopped
|
|
386
|
-
* growing. Silently, for as long as that file sat in the directory.
|
|
387
|
-
*
|
|
388
|
-
* The election has to be decided over the decks that are actually going to be
|
|
389
|
-
* handed the payload, and the only thing that establishes that is the handshake.
|
|
390
|
-
* So: prove, then elect, then post. It costs no extra round trip, only this
|
|
391
|
-
* ordering, and it is the same reordering src/server/index.mjs makes in
|
|
392
|
-
* readLiveDecks for the Codex rollouts no hook ever sees.
|
|
393
|
-
*
|
|
394
|
-
* The record is NOT unlinked when a target fails. A dead pid is proof the deck
|
|
395
|
-
* is gone and is swept above; a failed challenge is not — a deck restarting
|
|
396
|
-
* under its supervisor refuses connections for a moment while its record still
|
|
397
|
-
* stands, and a merely busy one can miss the 400ms deadline. Deleting another
|
|
398
|
-
* deck's registration on that evidence trades a bug that loses log lines for one
|
|
399
|
-
* that loses a whole deck's events, and it buys nothing now that the election no
|
|
400
|
-
* longer believes the record: a ghost that survives on disk costs one instant
|
|
401
|
-
* ECONNREFUSED per hook run and decides nothing.
|
|
402
|
-
*/
|
|
403
|
-
function proveTargets(targets, cb) {
|
|
404
|
-
const ok = new Array(targets.length).fill(false);
|
|
405
|
-
let pending = targets.length;
|
|
406
|
-
const settle = () => { if (--pending <= 0) cb(targets.filter((_, i) => ok[i])); };
|
|
407
|
-
targets.forEach((d, i) => prove(d, answered => { ok[i] = answered; settle(); }));
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Hand this deck the payload. `done` runs exactly once, whatever the outcome —
|
|
412
|
-
* a delivered event, a refused connection and a socket that errors after the
|
|
413
|
-
* response are all just "this target is finished".
|
|
414
|
-
*
|
|
415
|
-
* `persists` is this deck's answer from electWriters: true for the one deck that
|
|
416
|
-
* logs the event, false for every other one it is also drawn on.
|
|
417
|
-
*/
|
|
418
|
-
function post(d, body, persists, done) {
|
|
419
|
-
let settled = false;
|
|
420
|
-
const finish = () => { if (settled) return; settled = true; done(); };
|
|
421
|
-
const req = http.request({
|
|
422
|
-
hostname: "127.0.0.1",
|
|
423
|
-
port: d.port,
|
|
424
|
-
// Only the elected deck records the event; the rest are asked to draw it
|
|
425
|
-
// and keep no copy, so one log file ends up with one copy of it.
|
|
426
|
-
path: persists ? "/api/event" : "/api/event?persist=0",
|
|
427
|
-
method: "POST",
|
|
428
|
-
headers: { "Content-Type": "application/json" },
|
|
429
|
-
timeout: POST_TIMEOUT_MS,
|
|
430
|
-
}, res => { res.resume(); res.on("end", finish); });
|
|
431
|
-
req.on("error", finish);
|
|
432
|
-
req.on("timeout", () => req.destroy());
|
|
433
|
-
req.write(body);
|
|
434
|
-
req.end();
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function main() {
|
|
438
|
-
// Hard cap so a stuck server can never wedge the host CLI. 1900ms, which is
|
|
439
|
-
// the challenge's two attempts (400 + 400) plus the POST's 1000 with a little
|
|
440
|
-
// room — and still under the two-second timeout the installed hook entry
|
|
441
|
-
// carries, so this process ends itself rather than being killed.
|
|
442
|
-
setTimeout(() => process.exit(0), 1900);
|
|
443
|
-
|
|
444
|
-
// The deck reads the Claude quota by running `claude --print /usage`, which is
|
|
445
|
-
// a full Claude Code invocation and therefore fires these hooks. Reporting it
|
|
446
|
-
// drew a session onto the canvas for every quota poll — no prompt, no tools,
|
|
447
|
-
// a few seconds long — so the deck filled up with its own measurements. The
|
|
448
|
-
// probe sets this in the environment and hooks inherit it.
|
|
449
|
-
if (process.env.AGENTS_DECK_INTERNAL === "1") process.exit(0);
|
|
450
|
-
|
|
451
|
-
let input = "";
|
|
452
|
-
process.stdin.setEncoding("utf8");
|
|
453
|
-
process.stdin.on("data", c => { input += c; });
|
|
454
|
-
process.stdin.on("end", () => {
|
|
455
|
-
let parsed;
|
|
456
|
-
try { parsed = JSON.parse(input); } catch { return process.exit(0); }
|
|
457
|
-
const cwd = parsed && parsed.cwd;
|
|
458
|
-
if (!cwd) return process.exit(0);
|
|
459
|
-
|
|
460
|
-
// Stamp provider so the server / reducer can branch on it without
|
|
461
|
-
// re-sniffing payload shape.
|
|
462
|
-
if (parsed && typeof parsed === "object" && !parsed.provider) {
|
|
463
|
-
parsed.provider = PROVIDER;
|
|
464
|
-
}
|
|
465
|
-
const taggedInput = JSON.stringify(parsed);
|
|
466
|
-
|
|
467
|
-
const resolvedCwd = normPath(cwd);
|
|
468
|
-
|
|
469
|
-
let files;
|
|
470
|
-
try {
|
|
471
|
-
files = fs.readdirSync(DIR).filter(f => f.endsWith(".json"));
|
|
472
|
-
} catch { return process.exit(0); }
|
|
473
|
-
if (!files.length) return process.exit(0);
|
|
474
|
-
|
|
475
|
-
// Every deck whose workspace contains this cwd, and nothing else decides it.
|
|
476
|
-
//
|
|
477
|
-
// This used to sort the matches by how long each deck's workspace path was
|
|
478
|
-
// and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
|
|
479
|
-
// that tree's sessions away from a machine-wide deck, which then sat there
|
|
480
|
-
// showing nothing while `--all` promised it captured every session on this
|
|
481
|
-
// machine. Nothing documented that, and the server's own Codex capture never
|
|
482
|
-
// did it: each deck tails the rollout files itself and evaluates its own
|
|
483
|
-
// workspace, so a Codex session inside a scoped tree appeared on both decks
|
|
484
|
-
// while the Claude session beside it appeared on one. One flag, one path,
|
|
485
|
-
// two answers.
|
|
486
|
-
//
|
|
487
|
-
// The fan-out is the documented meaning and the one kept: `--workspace` says
|
|
488
|
-
// which sessions a deck captures, not which sessions it takes from the decks
|
|
489
|
-
// around it. It is also what electWriters below already assumes — several
|
|
490
|
-
// decks drawing one event is the case it exists to keep from being written
|
|
491
|
-
// to one log several times.
|
|
492
|
-
const targets = [];
|
|
493
|
-
for (const file of files) {
|
|
494
|
-
let d;
|
|
495
|
-
try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
|
|
496
|
-
if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
|
|
497
|
-
// A missing token is not a reason to drop the file here — prove() decides
|
|
498
|
-
// what a target has to prove, and a deck older than the handshake can
|
|
499
|
-
// prove nothing. See requiresProof.
|
|
500
|
-
|
|
501
|
-
if (!isAlive(d.pid)) {
|
|
502
|
-
try { fs.unlinkSync(path.join(DIR, file)); } catch {}
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// "" is machine-wide and must never reach normPath: resolving it would
|
|
507
|
-
// produce this hook's own cwd — the agent's — and scope a deck that asked
|
|
508
|
-
// for no scope at all. Any other spelling is canonicalized here, which is
|
|
509
|
-
// now a second pass over a path bin/deck.js already canonicalized before
|
|
510
|
-
// publishing it — kept because a deck old enough to have published a
|
|
511
|
-
// relative one is still entitled to its events.
|
|
512
|
-
const ws = d.workspace === "" ? "" : normPath(d.workspace);
|
|
513
|
-
if (capturesSession(resolvedCwd, ws)) targets.push(d);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
if (!targets.length) return process.exit(0);
|
|
517
|
-
|
|
518
|
-
// Prove, elect, post — in that order, and see proveTargets for what the
|
|
519
|
-
// other order cost. A record whose pid is merely alive has established
|
|
520
|
-
// nothing: it may be a deck that died and had its pid recycled, and electing
|
|
521
|
-
// one of those to write the log meant nobody wrote it (#695).
|
|
522
|
-
proveTargets(targets, proven => {
|
|
523
|
-
if (!proven.length) return process.exit(0);
|
|
524
|
-
|
|
525
|
-
// One deck per events log records this event; the others only draw it.
|
|
526
|
-
const writers = electWriters(proven);
|
|
527
|
-
|
|
528
|
-
let pending = proven.length;
|
|
529
|
-
const done = () => { if (--pending <= 0) process.exit(0); };
|
|
530
|
-
|
|
531
|
-
for (const d of proven) post(d, taggedInput, writers.has(d), done);
|
|
532
|
-
});
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// The host CLI always runs this file as the process entry point — the command
|
|
537
|
-
// the installer writes is `"<node>" "<...>/hook.js" --provider <name>`. Under a
|
|
538
|
-
// require() it exports the rules it decides by — matching, election, the
|
|
539
|
-
// handshake — and starts nothing, which is what lets them be tested without a
|
|
540
|
-
// 1.5s exit timer in the test runner.
|
|
541
|
-
module.exports = { capturesSession, cwdInWorkspace, foldsCase, normPath, electWriters, challengeProof, requiresProof };
|
|
542
|
-
if (require.main === module) main();
|