agent-dag 3.22.1 → 3.22.4

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-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  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,91 +0,0 @@
1
- // Reading LibreHardwareMonitor's sensor tree, out of the shape its own source
2
- // produces rather than out of an example.
3
- //
4
- // `GenerateJsonForNode` in HttpServer.cs builds every node as
5
- //
6
- // { id, Text, Min, Value, Max }
7
- //
8
- // and a SENSOR node adds
9
- //
10
- // SensorId : "/intelcpu/0/temperature/0" the stable identifier
11
- // Type : "Temperature" the SensorType enum, as text
12
- // Value : "52.0 °C" formatted for a human
13
- // RawValue : 52.0 the number, unformatted
14
- //
15
- // Two of those decide this file.
16
- //
17
- // RAWVALUE, NEVER VALUE. `Value` is formatted with the machine's culture, so on
18
- // a German or Russian Windows it reads "52,0 °C" — a comma — and every naive
19
- // parse of it either throws away the decimal or produces 520. `RawValue` is the
20
- // number itself. This is the same trap the deck already hit once with `ps`
21
- // output and fixed with LC_NUMERIC.
22
- //
23
- // AND RAWVALUE CAN BE THE STRING "NaN". The server serialises with
24
- // JsonNumberHandling.AllowNamedFloatingPointLiterals, which writes a NaN as a
25
- // quoted "NaN" rather than failing — so a sensor that has not read yet arrives
26
- // as text where a number is expected. `Number("NaN")` is NaN and is rejected
27
- // below, which is the right answer, but it is rejected on purpose rather than
28
- // by luck.
29
- //
30
- // TYPE, NEVER THE NAME. `Type` comes from an enum and is the same word on every
31
- // machine in every language; the `Text` beside it is a display name that
32
- // differs between vendors and driver versions.
33
-
34
- /** A plausible temperature. The same floor the rest of the thermal code uses:
35
- * 0 is a sensor that has not read, and nothing above this is a temperature. */
36
- const plausible = (c) => Number.isFinite(c) && c > 0 && c < 150;
37
-
38
- /**
39
- * Every temperature sensor in the tree, flattened.
40
- *
41
- * Exported for its own test, and because "what did that machine actually
42
- * publish" is the question anybody debugging this will have first.
43
- */
44
- export function flattenSensors(root) {
45
- const out = [];
46
- const walk = (n, depth) => {
47
- // The tree is four or five deep in practice — root, computer, hardware,
48
- // type, sensor. The bound is against a cycle, which JSON cannot contain but
49
- // a hand-written fixture can.
50
- if (!n || typeof n !== "object" || depth > 12) return;
51
- if (n.Type === "Temperature") {
52
- out.push({
53
- id: String(n.SensorId ?? ""),
54
- name: String(n.Text ?? ""),
55
- celsius: Number(n.RawValue),
56
- });
57
- }
58
- if (Array.isArray(n.Children)) for (const c of n.Children) walk(c, depth + 1);
59
- };
60
- walk(root, 0);
61
- return out;
62
- }
63
-
64
- /**
65
- * Which sensor is the CPU, and which is the GPU.
66
- *
67
- * Chosen by SensorId rather than by name. The identifier is built from the
68
- * hardware type — `/intelcpu/0/…`, `/amdcpu/0/…`, `/gpu-nvidia/0/…` — and is
69
- * the same on every machine, while `Text` is a display name that reads "CPU
70
- * Package" on one driver and "Core (Tctl/Tdie)" on another.
71
- *
72
- * The HOTTEST of a hardware's sensors is taken, not the first. A CPU publishes
73
- * a package reading and one per core; the package is usually the highest and is
74
- * what a person means by "the CPU temperature", and where a vendor publishes no
75
- * package the hottest core is the honest stand-in. Taking the first would
76
- * report core #1 while core #6 is thermal-throttling.
77
- */
78
- export function readTemps(root) {
79
- const sensors = flattenSensors(root).filter(s => plausible(s.celsius));
80
- const hottest = (re) => {
81
- const mine = sensors.filter(s => re.test(s.id));
82
- if (!mine.length) return null;
83
- return Math.round(Math.max(...mine.map(s => s.celsius)));
84
- };
85
- const out = {};
86
- const cpu = hottest(/^\/(intel|amd)cpu\//i);
87
- const gpu = hottest(/^\/gpu-/i);
88
- if (cpu != null) out.cpu = cpu;
89
- if (gpu != null) out.gpu = gpu;
90
- return out;
91
- }
@@ -1,139 +0,0 @@
1
- // Reading a log backwards, because only its end is ever kept.
2
- //
3
- // #742. The event log is replayed before the port opens, and the replay parsed
4
- // every line in it from the beginning — 12,079 lines and 31 MB on the machine
5
- // this was measured on, 690ms of JSON.parse, growing with every session until
6
- // rotation cuts it at 50 MB. The ring it fills holds MAX_BUFFER = 2000 events.
7
- // So roughly five sixths of that work was parsing events that were evicted by
8
- // the ones parsed after them, on the critical path of a boot, every time.
9
- //
10
- // Reading from the end fixes the asymmetry rather than the constant: the loop
11
- // stops as soon as the ring is full, so the cost becomes a property of the ring
12
- // instead of a property of how long the user has been running the deck. When
13
- // the ring cannot be filled — a young log, or a workspace-scoped deck whose
14
- // events are a thin slice of a shared one — it reads all the way back to the
15
- // start and costs exactly what the forward read did. There is no case where
16
- // this is slower and no case where it sees less.
17
- //
18
- // Bytes, not characters. Lines are split on 0x0A and decoded one at a time,
19
- // which is safe in UTF-8 because no byte of a multi-byte sequence can be 0x0A —
20
- // a chunk boundary landing inside a three-byte character joins back together
21
- // before anything is decoded. Decoding the chunk first and splitting the string
22
- // is what would corrupt it, and it is the obvious way to write this.
23
- import { open } from "node:fs/promises";
24
- import { createReadStream } from "node:fs";
25
- import { createInterface } from "node:readline";
26
-
27
- /** One line out of a buffer, minus the carriage return a CRLF file leaves on
28
- * the end of it. Sliced before decoding — see the note about 0x0A above. */
29
- function line(buf, from, to) {
30
- const end = to > from && buf[to - 1] === 0x0D ? to - 1 : to;
31
- return buf.subarray(from, end).toString("utf8");
32
- }
33
-
34
- /** How much is read at a time. One megabyte holds about 400 events at the size
35
- * this log's lines actually run to, so a full ring is usually five reads. */
36
- export const CHUNK_BYTES = 1 << 20;
37
-
38
- /**
39
- * The file's lines, newest first, as an async iterable.
40
- *
41
- * Stops reading the moment the consumer stops asking — that is the whole point,
42
- * and it is why this is a generator rather than a function returning an array.
43
- * A `break` in the caller closes the handle through the generator's `finally`.
44
- *
45
- * Lines are yielded WITHOUT their newline, and the sequence is exactly the
46
- * reverse of what `readline` yields reading the same file forwards — empty
47
- * lines included, and a trailing newline at EOF is a line terminator rather
48
- * than an empty line after it. A CRLF file reads the same as it does forwards,
49
- * because the carriage return is stripped here too. That equivalence is the
50
- * contract, and it is what the round-trip case in the test file checks against
51
- * readline itself rather than against a hand-written expectation.
52
- *
53
- * The ONE difference: `readline` also breaks on a lone carriage return, for the
54
- * sake of files written by software that predates OS X. Walking those backwards
55
- * would mean scanning every byte of every chunk instead of asking Buffer for
56
- * the next 0x0A, and nothing that writes a line into this deck's log has
57
- * produced one since 2001. A file full of lone carriage returns reads here as a
58
- * single very long line, which JSON.parse then declines — one skipped line,
59
- * counted and reported, rather than a wrong answer.
60
- */
61
- export async function* linesFromEnd(filePath, { chunkBytes = CHUNK_BYTES, openFile = open } = {}) {
62
- const fh = await openFile(filePath, "r");
63
- try {
64
- const { size } = await fh.stat();
65
- let pos = size;
66
- // The bytes at the front of what has been read that have no newline before
67
- // them yet: the first line of the chunk, which may continue into the chunk
68
- // that comes before it. Carried, never yielded, until a newline turns up or
69
- // the start of the file does.
70
- let carry = Buffer.alloc(0);
71
- // The newline that ends the last line is a terminator, not the start of an
72
- // empty line after it. Trimmed once, on the chunk that holds EOF.
73
- let atEof = true;
74
-
75
- while (pos > 0) {
76
- const len = Math.min(chunkBytes, pos);
77
- pos -= len;
78
- const buf = Buffer.alloc(len);
79
- // Node reads short at the end of a file and at a pipe; a regular file
80
- // opened for reading at a known offset does not, but the loop is written
81
- // to survive it rather than to assume it.
82
- let got = 0;
83
- while (got < len) {
84
- const { bytesRead } = await fh.read(buf, got, len - got, pos + got);
85
- if (bytesRead === 0) break;
86
- got += bytesRead;
87
- }
88
- const hay = carry.length ? Buffer.concat([buf.subarray(0, got), carry]) : buf.subarray(0, got);
89
-
90
- // Walk the newlines from the end. `end` is one past the last byte of the
91
- // line being cut; every cut is a complete line, because everything to its
92
- // right has already been yielded.
93
- let end = hay.length;
94
- if (atEof && end > 0 && hay[end - 1] === 0x0A) end--;
95
- atEof = false;
96
- while (end > 0) {
97
- const nl = hay.lastIndexOf(0x0A, end - 1);
98
- if (nl === -1) break;
99
- yield line(hay, nl + 1, end);
100
- end = nl;
101
- }
102
- carry = hay.subarray(0, end);
103
- }
104
-
105
- // Whatever is left has the start of the file in front of it, so it is a
106
- // whole line — and an EMPTY one is still a line, which is why this is not
107
- // conditional on `carry.length`. A file that opens with a newline opens
108
- // with an empty line, and reading it forwards says so.
109
- if (size > 0) yield line(carry, 0, carry.length);
110
- } finally {
111
- await fh.close().catch(() => {});
112
- }
113
- }
114
-
115
- /**
116
- * The same file, the ordinary way round.
117
- *
118
- * Here rather than at the one call site so the two readers sit together and a
119
- * reader of either finds the other: replayLog picks between them per boot, on
120
- * whether its scope predicate can be fed a log backwards, and a pair of
121
- * functions in one file is what makes that choice legible.
122
- *
123
- * Streams, so a scoped deck on a 50 MB log holds one line at a time exactly as
124
- * it did before any of this.
125
- */
126
- export async function* linesFromStart(filePath) {
127
- const input = createReadStream(filePath, { encoding: "utf8" });
128
- const rl = createInterface({ input });
129
- try {
130
- for await (const line of rl) yield line;
131
- } finally {
132
- // Both, and the stream second. `rl.close()` stops the interface and leaves
133
- // the descriptor under it open, which is fine for the one caller here
134
- // because it reads to the end — and is a leak the day somebody breaks out
135
- // of this loop the way the backwards reader is designed to be broken out of.
136
- rl.close();
137
- input.destroy();
138
- }
139
- }
@@ -1,322 +0,0 @@
1
- // Which of the running decks writes an event to the log they share?
2
- //
3
- // The hook already answers that for the events it delivers: it groups the decks
4
- // it is about to post to by the log file each one names in its discovery record,
5
- // elects one per file, and marks the request to every other one `?persist=0`.
6
- // See electWriters in hook/hook.js.
7
- //
8
- // The Codex rollout watcher never goes through the hook — it builds its events
9
- // inside the server by tailing ~/.codex/sessions/**/rollout-*.jsonl — so nothing
10
- // suppressed the copies on that path: every deck tailing the same rollout
11
- // appended its own line to the one events.jsonl they all default to, so each
12
- // Codex tool call, prompt and session start landed there once per running deck.
13
- // That is the duplication the hook election was added to end, still open on the
14
- // path that is the only Codex capture there is on Windows, where Codex hooks
15
- // never fire at all.
16
- //
17
- // So the server runs the same election, over the same discovery records, with
18
- // the same tie-break. The rule is repeated here rather than imported from
19
- // hook/hook.js because that file is copied out of the package and run standalone
20
- // by the host CLI, with no path back to the module it came from — the same
21
- // reason it re-derives the Claude config dir inline. The two copies are pinned
22
- // equal by a test, as challengeProof's pair already is.
23
- import { open } from "node:fs/promises";
24
- import { realpathSync } from "node:fs";
25
- import { basename, dirname, join, resolve, win32, posix } from "node:path";
26
-
27
- /**
28
- * Does this platform's filesystem treat two spellings that differ only in case
29
- * as the same file? The platform is a parameter so both answers can be checked
30
- * from either kind of machine.
31
- *
32
- * Windows always does, and macOS does by default (APFS and HFS+ are formatted
33
- * case-insensitive unless the user deliberately chose otherwise). Linux does
34
- * not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
35
- * /srv/A/events.jsonl are two real files, each of which needs a writer.
36
- */
37
- /**
38
- * The one spelling of an events log, so two decks pointed at one file land in
39
- * one group (#793).
40
- *
41
- * `resolve` alone was what shipped, and it settles relative-vs-absolute and
42
- * nothing else. The election below then only case-folds — so two spellings of
43
- * one file read as two files, which is the exact thing `bin/deck.js`'s comment
44
- * over this value says must not happen. On Windows it needs no odd user action:
45
- * `claudeConfigDir()` derives from `homedir()`, and a shell whose `USERPROFILE`
46
- * is 8.3-shortened yields a different default string than one with the long
47
- * form. `subst` and mapped drives and junctions do it too, and on macOS so does
48
- * `/tmp` against `/private/tmp`.
49
- *
50
- * What it cost was not merely a duplicate group. BOTH decks were then elected,
51
- * so every hook event was appended twice — the duplicate-tools-after-restart
52
- * symptom the election exists to end — and `logSharing()` compares the same
53
- * string, so both answered `mine: true` and `POST /api/clear` truncated a file
54
- * this deck does not own. That is the #698 history loss the ownership gate was
55
- * added to prevent.
56
- *
57
- * THE DIRECTORY IS CANONICALISED EVEN WHEN THE FILE IS NOT THERE, which is the
58
- * half a plain realpath misses. On a first run, or against a `--history` naming
59
- * a file the deck will create, `realpath` throws ENOENT — and falling back to
60
- * the resolved string would leave the two spellings different for exactly the
61
- * run that creates the file. The parent exists (or is about to be created under
62
- * one canonical name), so it is canonicalised and the basename rejoined.
63
- *
64
- * `canonicalWorkspace` in index.mjs is the same rule for the other path this
65
- * deck publishes, with three comments naming 8.3 expansion as its reason. This
66
- * is that rule reaching the value two lines away from it.
67
- */
68
- export function canonicalLogPath(raw) {
69
- if (typeof raw !== "string" || raw.trim() === "") return "";
70
- const abs = resolve(raw);
71
- try { return realpathSync.native(abs); } catch { /* not created yet */ }
72
- try { return join(realpathSync.native(dirname(abs)), basename(abs)); } catch { return abs; }
73
- }
74
-
75
- export const foldsCase = (platform = process.platform) =>
76
- platform === "win32" || platform === "darwin";
77
-
78
- /**
79
- * Of these decks, which ones write to disk? Returns the subset that should;
80
- * every other one is expected to draw the event and keep no record of it.
81
- *
82
- * Decks are grouped by the log file each one names in its discovery record and
83
- * one deck per group is elected. Grouping by the file rather than counting decks
84
- * is what keeps the overrides honest: a deck run with `--history` sits alone in
85
- * its own group and always writes, a deck run with `--no-persist` reports no
86
- * file and can never be elected to write for one that does, and a deck too old
87
- * to report either keeps the behaviour it had before this rule existed. Within a
88
- * group the lowest port wins — a fixed rule, so the same deck holds the file for
89
- * as long as it is up and the next one inherits it as soon as that deck is gone.
90
- *
91
- * Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
92
- * for the same decks over the same records, and a disagreement between them
93
- * means one log line written twice or none at all.
94
- */
95
- export function electWriters(decks, platform = process.platform) {
96
- const byLog = new Map();
97
- for (const d of decks) {
98
- const log = typeof d.persist === "string" ? d.persist : "";
99
- // Two namespaces, so a deck with no log to share — and a deck too old to
100
- // report one — is alone in its group and cannot collide with a real path.
101
- const key = log
102
- ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
103
- : `deck:${d.pid}:${d.port}`;
104
- const held = byLog.get(key);
105
- // Ports are unique among live decks; pid only breaks a tie a stale
106
- // discovery file could invent, so the answer stays deterministic.
107
- if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
108
- byLog.set(key, d);
109
- }
110
- }
111
- return new Set(byLog.values());
112
- }
113
-
114
- /**
115
- * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
116
- * empty workspace is unscoped and captures every session; a rollout that never
117
- * said where it runs is inside no workspace, so only an unscoped deck draws it.
118
- *
119
- * This answers two questions with one function — whether THIS deck tails a
120
- * rollout, and whether another deck tails it too — and that is only sound while
121
- * the rule below is the rule every deck actually runs. Model another deck's
122
- * capture with anything else and the election covers the wrong set: a deck that
123
- * writes without being elected, or an elected deck that never opened the file.
124
- *
125
- * It is also the rule hook/hook.js runs for the sessions it delivers, under the
126
- * name capturesSession — that script is copied out of the package and run
127
- * standalone, so the two are written twice and pinned equal by a test walking
128
- * one table of paths through both. They were not equal: case was folded here on
129
- * every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
130
- * from /srv/Proj and Claude sessions from neither. Those are two real
131
- * directories there, and the hook's own comment says what folding them together
132
- * costs — a deck handed the events of a tree it was not scoped to. So the fold
133
- * is per-platform on both sides now, and `--workspace` means one thing.
134
- *
135
- * (The narrow window that opens: two decks on Linux whose workspaces differ only
136
- * in case, one of them old enough to still fold, both containing one rollout's
137
- * cwd. Each models the other as tailing the file; one of them is wrong, and the
138
- * cost is a single log line written twice.)
139
- *
140
- * The platform is a parameter, following the hook's cwdInWorkspace and
141
- * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
142
- * POSIX machine.
143
- */
144
- export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
145
- if (!workspace || typeof workspace !== "string") return true;
146
- if (!cwd || typeof cwd !== "string") return false;
147
- const p = platform === "win32" ? win32 : posix;
148
- const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
149
- const a = fold(p.resolve(cwd));
150
- const b = fold(p.resolve(workspace));
151
- if (a === b) return true;
152
- // A root ("C:\", "/") already ends in the separator; appending a second one
153
- // would match nothing.
154
- return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
155
- }
156
-
157
- /**
158
- * Does this deck append a rollout's events to its log, or is another deck doing
159
- * it? `decks` is every deck registered right now, `pid` identifies this one
160
- * among them, and `cwd` is the workspace the rollout is running in.
161
- *
162
- * The group is every deck that tails this same rollout: each deck decides for
163
- * itself, so all of them whose workspace contains the cwd read the file and all
164
- * of them would write it. The hook builds the same group the same way for the
165
- * events it delivers — it used to narrow them to the longest workspace match
166
- * first, which is the asymmetry the predicate above describes the end of. A deck
167
- * started with `--no-codex` tails nothing and is left out; electing it would
168
- * mean the rollout's events reach no log at all. A deck too old to say either
169
- * way is assumed to be tailing, which is what it was doing before this field
170
- * existed.
171
- */
172
- export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
173
- const live = Array.isArray(decks) ? decks : [];
174
- const self = live.find(d => d && d.pid === pid) ?? null;
175
- // No record of our own on disk — the window before the first heartbeat writes
176
- // it, or a deck that cannot write one at all. Nobody can elect us and we
177
- // cannot see who else is here, so keep what this deck did before the election
178
- // existed and write. A line written twice is recoverable; a deck that quietly
179
- // stops recording anything is not.
180
- if (!self || typeof self.persist !== "string" || self.persist === "") return true;
181
-
182
- const group = [self];
183
- for (const d of live) {
184
- if (!d || d.pid === self.pid) continue;
185
- if (d.codex === false) continue;
186
- if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
187
- group.push(d);
188
- }
189
- return electWriters(group, platform).has(self);
190
- }
191
-
192
- // ─── Appending one whole line ─────────────────────────────────────────────
193
- //
194
- // The election above decides WHICH deck writes a line. This decides HOW, and
195
- // the two are answers to the same question: the log is one file that several
196
- // processes append to, so a line that arrives in pieces is a line another
197
- // writer can land inside.
198
- //
199
- // What was wrong. events.jsonl was appended with `fsPromises.appendFile`, which
200
- // is not one write(2). Node's writeFileHandle loops over the payload in chunks
201
- // of kWriteFileMaxChunkSize — 512 KiB — awaiting each one, so any line longer
202
- // than 524288 bytes became two or more separate appends with the event loop
203
- // free in between. Measured here, on Node 26 / macOS 15 / APFS, by watching the
204
- // file grow while one 5 MiB appendFile was in flight: it arrived in exactly ten
205
- // steps of 524288 bytes. A single handle.write of the same payload arrived in
206
- // one step of 5242880.
207
- //
208
- // That is reachable in ordinary use. `POST /api/event` accepts a body up to
209
- // 5,000,000 characters, and a PostToolUse carrying a large Read or Bash
210
- // response is routinely a good fraction of that. The event that gets spliced
211
- // into the middle is nearly always this deck's own: pushEvent kicks off
212
- // maybeResolveUsage / maybeResolveModel / maybeResolveContext on the same call,
213
- // and each of those pushes its own event a moment later. Reproduced at 8/8 runs
214
- // with one 1 MiB line and eight small ones issued in one tick, and 4/4 runs
215
- // across two processes appending 3 MiB lines to one file.
216
- //
217
- // The damage is silent and doubled: the oversized event is torn into two
218
- // unparseable halves AND the small event that landed between them is swallowed
219
- // inside one of those halves, so a boot replay loses both.
220
- //
221
- // What this does instead: exactly one write(2) per line, on a descriptor opened
222
- // O_APPEND. That is the primitive the atomicity rests on, so it is worth
223
- // stating exactly what each platform promises.
224
- //
225
- // Linux — a write to a regular file holds the inode's i_rwsem for the whole
226
- // call, so one write(2) is atomic against every other writer regardless of
227
- // size, up to MAX_RW_COUNT (~2 GiB) after which the call returns short.
228
- // O_APPEND additionally makes the seek-to-end and the write one step, which
229
- // is what stops two processes overwriting each other's tail.
230
- //
231
- // macOS — measured, not assumed, because the guarantee is not written down as
232
- // plainly: single writes of 1 MiB and 5 MiB in-process, and 3 MiB from two
233
- // processes at once, produced no torn line in any run, while appendFile of
234
- // the same payloads tore in every run.
235
- //
236
- // Windows — libuv issues the append as a WriteFile at the documented
237
- // end-of-file offset on a handle opened FILE_APPEND_DATA, which NTFS serialises
238
- // per call. This is the platform where the Codex rollout watcher is the only
239
- // capture path there is, so it is also the platform with the most decks
240
- // sharing one log.
241
- //
242
- // Where the guarantee does NOT hold: any filesystem that can return a short
243
- // write — NFS and some network/FUSE mounts under memory pressure, and any
244
- // platform once the payload passes its per-call ceiling. The loop below then
245
- // issues a second write for the remainder, and another writer can land between
246
- // them. Nothing available in userland fixes that, so the answer is the reader:
247
- // replayLog skips a line it cannot parse, counts it, and says so, rather than
248
- // stopping at it. A torn line costs one event, not the rest of the file.
249
- //
250
- // Why not cap or drop oversized events instead. The 5 MB ceiling is already the
251
- // cap, and it is enforced where it belongs — at ingest, with a 413 the poster
252
- // can see. Refusing to persist an event the deck is happily drawing would make
253
- // the canvas and its own replay disagree, which is the bug this fixes wearing a
254
- // different hat.
255
- //
256
- // Why the file is opened per line rather than kept open. maybeRotatePersistFile
257
- // renames events.jsonl to events.jsonl.1 at 50 MB and `/api/clear` truncates it
258
- // to zero; a descriptor held across either would go on filling the file nobody
259
- // reads any more. Opening per line is also exactly the syscall count appendFile
260
- // already paid — open, write, close — minus the extra writes.
261
- const appendTails = new Map();
262
-
263
- /**
264
- * Append one already-serialized line to the shared log, whole.
265
- *
266
- * Appends are serialized per file behind a promise chain. That is not what
267
- * makes them atomic — the single write(2) below is — but it keeps the order the
268
- * lines land in equal to the order pushEvent produced them, which is the order
269
- * a replay reads them back in, and it keeps this process to one open descriptor
270
- * on the log no matter how many events arrive in one tick.
271
- *
272
- * Never rejects. This is called fire-and-forget from the hottest path in the
273
- * process, and a rejection nobody awaits is a dead deck; a line that could not
274
- * be written is a line lost, which the caller could not have done anything
275
- * about anyway. The chain deliberately continues past a failure — one ENOSPC
276
- * must not stop every later event from being recorded once space is back.
277
- *
278
- * @param {string} filePath absolute path to the log
279
- * @param {string} line the line to append, INCLUDING its trailing newline
280
- */
281
- export function appendLogLine(filePath, line) {
282
- const tail = (appendTails.get(filePath) ?? Promise.resolve())
283
- .then(() => writeWholeLine(filePath, line))
284
- .catch(() => {});
285
- appendTails.set(filePath, tail);
286
- // Drop the chain once it drains, so a process that writes to several logs
287
- // over its life does not hold a promise per path it has finished with. Only
288
- // the tail we just installed is cleared: if another append chained on in the
289
- // meantime the map already points at that one, and deleting it would let the
290
- // next line race the one still in flight.
291
- tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
292
- return tail;
293
- }
294
-
295
- /**
296
- * One line, one write(2). The loop exists only for the short-write case
297
- * described above; on every filesystem that does not do that it runs once.
298
- *
299
- * `position` is left null on purpose. That is what makes libuv issue write(2)
300
- * rather than pwrite(2), and pwrite is the version that does not honour
301
- * O_APPEND on every platform — it would write at an offset computed before the
302
- * other writer moved the end of the file.
303
- */
304
- async function writeWholeLine(filePath, line) {
305
- // Encoded up front so the short-write loop can count bytes rather than UTF-16
306
- // units. A multi-byte character split across two chunks would otherwise be
307
- // resumed mid-sequence and the line would be mojibake even without a race.
308
- const buf = Buffer.from(line, "utf8");
309
- const handle = await open(filePath, "a");
310
- try {
311
- let written = 0;
312
- while (written < buf.byteLength) {
313
- const { bytesWritten } = await handle.write(buf, written, buf.byteLength - written, null);
314
- // A write that reports no progress would spin this loop forever inside a
315
- // promise nobody is watching. Give up on the line instead.
316
- if (bytesWritten <= 0) throw new Error(`append made no progress at ${written}/${buf.byteLength} bytes`);
317
- written += bytesWritten;
318
- }
319
- } finally {
320
- await handle.close();
321
- }
322
- }