ccdeck 1.43.0 → 1.45.0

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.
@@ -0,0 +1,144 @@
1
+ // Which of the three published commands the user typed — and nothing else.
2
+ //
3
+ // The deck is on npm three times over — `ccdeck`, `agents-deck` and `agent-dag`
4
+ // — and since #340 all three are literally the same tarball, published with
5
+ // `name` set to each in turn. Every surface a human reads says ccdeck now, and
6
+ // most of the downloads are still on the other two, so nothing closes that split
7
+ // on its own. This file is the evidence behind saying so — once in the terminal,
8
+ // once in the browser.
9
+ //
10
+ // Two rules make that safe, and they are the whole of the file.
11
+ //
12
+ // It is never a refusal. A build that declined to boot under an old name would
13
+ // take the self-update path down with it: a user on `agents-deck` clicks
14
+ // "Update & restart", the copy that arrives refuses to run, and the deck is
15
+ // dead on the one machine where the deck was the thing that would have
16
+ // explained why.
17
+ //
18
+ // And it is never asked of the PACKAGE. That was unarguable while ccdeck was a
19
+ // stub — it depended on `agents-deck` and spawned its bin, so "is this build
20
+ // agents-deck?" was true inside every ccdeck run, and a notice keyed on it would
21
+ // have scolded exactly the people who did what we asked. #340 removed the stub
22
+ // and the rule outlived it, because the three names are now ONE tarball: asking
23
+ // the package what it is gets you whichever name the publish step happened to
24
+ // set last, which is not evidence about the user at all. The question is which
25
+ // name the USER TYPED. That is a different question, with a different answer,
26
+ // and on one platform with no answer at all:
27
+ //
28
+ // npx, everywhere — npm writes the literal spec into
29
+ // `_npx/<hash>/package.json` as `_npx.packages`, which npxRestartSpec
30
+ // already reads to decide what a restart re-runs. Reused rather than
31
+ // re-derived; the only difference here is that this caller has no fallback
32
+ // to offer, since a guess is the one answer it must not give.
33
+ //
34
+ // a global install on macOS or Linux — npm links <prefix>/bin/<name> at the
35
+ // package's bin/agent-dag.js, and node does not resolve that symlink for
36
+ // argv[1]. All three names reach one file and only argv[1] tells them
37
+ // apart, so its last segment is the command that was typed. Verified with
38
+ // npm's own layout: a symlink named `ccdeck` gives argv[1] `…/bin/ccdeck`.
39
+ //
40
+ // a global install on Windows — nowhere. npm writes <name>.cmd, <name>.ps1
41
+ // and an extensionless sh shim, each of which runs
42
+ // `node "…\node_modules\<pkg>\bin\agent-dag.js" %*` (npm's cmd-shim).
43
+ // The typed name is the shim's FILENAME and never becomes an argument, and
44
+ // there is no other carrier — npm_config_user_agent and friends are not set
45
+ // for a direct bin invocation. So Windows answers null.
46
+ //
47
+ // a git checkout — nothing was typed. `node bin/agent-dag.js` is not any of
48
+ // the three names.
49
+ //
50
+ // Which leaves the callers one rule: positive identification, or silence. A
51
+ // notice shown to somebody who already types `ccdeck` is worse than a notice
52
+ // missed, because it is the failure that teaches everyone to ignore the next
53
+ // one.
54
+ import { bareSpecName, isNpxInstall, npxRestartSpec } from "./self-update.mjs";
55
+
56
+ /** The command every surface promotes. Deliberately not brand.mjs's PRODUCT:
57
+ * that is the name a human reads, this is an npm bin name the user types, and
58
+ * the two are the same string only because the rename made them so —
59
+ * invoked-as.test.ts pins them against each other rather than assuming it. */
60
+ export const PREFERRED = "ccdeck";
61
+
62
+ /** Every command an install of this package provides, which is every name that
63
+ * can be typed. package.json's `bin` block is the other half of this list. */
64
+ export const COMMANDS = [PREFERRED, "agents-deck", "agent-dag"];
65
+
66
+ /** `value` when it is one of those three, and null for everything else — a shim
67
+ * filename with an extension on it, a path, a spec that resolved to some other
68
+ * package, an environment variable somebody set by hand. None of those are
69
+ * evidence of anything, and this function is the only place that decides so. */
70
+ export function knownCommand(value) {
71
+ return typeof value === "string" && COMMANDS.includes(value) ? value : null;
72
+ }
73
+
74
+ /** The last segment of a path, split on both separators rather than `path.sep`
75
+ * — the same reason isNpxInstall gives: a Windows path can reach a POSIX
76
+ * process through a test or a config file, and a separator-agnostic split is
77
+ * what keeps this one function on all three platforms. */
78
+ function lastSegment(path) {
79
+ if (typeof path !== "string") return null;
80
+ const parts = path.split(/[\\/]/);
81
+ return parts[parts.length - 1];
82
+ }
83
+
84
+ /**
85
+ * The name the user typed, when we can prove it. Null means unknown, and
86
+ * unknown must never produce a notice.
87
+ */
88
+ export function invokedAs({ pkgRoot, argv1, platform = process.platform } = {}) {
89
+ // npx first, and alone: under npx argv[1] is a path inside the cache
90
+ // directory, so the metadata is not the better answer, it is the only one.
91
+ // The explicit null fallback is the point of the call — npxRestartSpec's
92
+ // default is the package name, which is right for "what do I re-run" and
93
+ // would be a guess wearing an answer's clothes here.
94
+ if (isNpxInstall(pkgRoot)) return knownCommand(bareSpecName(npxRestartSpec(pkgRoot, null)));
95
+ // See the header: on Windows the shim eats the name. Decided on the platform
96
+ // rather than left to the extension check below, so that a Windows path that
97
+ // somehow did end in a bare name still cannot become a guess.
98
+ if (platform === "win32") return null;
99
+ return knownCommand(lastSegment(argv1));
100
+ }
101
+
102
+ /**
103
+ * The same question, answered for the process that is asking it.
104
+ *
105
+ * bin/deck.js — and the server running inside it — is spawned BY the
106
+ * supervisor, so its own argv[1] is `…/bin/deck.js` every single time: the
107
+ * typed name reached bin/agent-dag.js and stopped there. The supervisor answers
108
+ * once and passes the answer down in AGENTS_DECK_INVOKED_AS, which is read back
109
+ * through knownCommand rather than trusted, since an environment variable is
110
+ * user input like any other. The fall-through still answers the npx half for a
111
+ * worker started with no supervisor above it.
112
+ */
113
+ export function invokedName({
114
+ env = process.env, pkgRoot, argv1 = process.argv[1], platform = process.platform,
115
+ } = {}) {
116
+ return knownCommand(env?.AGENTS_DECK_INVOKED_AS) ?? invokedAs({ pkgRoot, argv1, platform });
117
+ }
118
+
119
+ /**
120
+ * What the terminal says about it, or null when there is nothing to say.
121
+ *
122
+ * Two lines, because the second one is where the value is. Someone who ran
123
+ * `npm i -g agents-deck` has nothing to install and nothing to download — the
124
+ * same install already put a `ccdeck` on their PATH, and all they have to do is
125
+ * type it. Handing them an install command instead would be work we invented.
126
+ * Under npx there is no such install, so the fix there is the command itself.
127
+ *
128
+ * `dash` comes from the caller's glyph tier: an em dash is as absent from a
129
+ * legacy Windows console as a check mark is, and this file must not be the one
130
+ * place that forgets it.
131
+ */
132
+ export function renameNotice({ invoked, pkgRoot, dash = "—", preferred = PREFERRED } = {}) {
133
+ const name = knownCommand(invoked);
134
+ if (!name || name === preferred) return null;
135
+ return {
136
+ // Leads with the reassurance on purpose. Nothing is breaking here, no
137
+ // command is being taken away, and a line that opened with the rename would
138
+ // read as one that was.
139
+ said: `${name} still works ${dash} the deck is called ${preferred} now`,
140
+ fix: isNpxInstall(pkgRoot)
141
+ ? `next time: npx ${preferred}`
142
+ : `${preferred} already works here ${dash} same install, no download`,
143
+ };
144
+ }
@@ -0,0 +1,283 @@
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 { win32, posix } from "node:path";
25
+
26
+ /**
27
+ * Does this platform's filesystem treat two spellings that differ only in case
28
+ * as the same file? The platform is a parameter so both answers can be checked
29
+ * from either kind of machine.
30
+ *
31
+ * Windows always does, and macOS does by default (APFS and HFS+ are formatted
32
+ * case-insensitive unless the user deliberately chose otherwise). Linux does
33
+ * not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
34
+ * /srv/A/events.jsonl are two real files, each of which needs a writer.
35
+ */
36
+ export const foldsCase = (platform = process.platform) =>
37
+ platform === "win32" || platform === "darwin";
38
+
39
+ /**
40
+ * Of these decks, which ones write to disk? Returns the subset that should;
41
+ * every other one is expected to draw the event and keep no record of it.
42
+ *
43
+ * Decks are grouped by the log file each one names in its discovery record and
44
+ * one deck per group is elected. Grouping by the file rather than counting decks
45
+ * is what keeps the overrides honest: a deck run with `--history` sits alone in
46
+ * its own group and always writes, a deck run with `--no-persist` reports no
47
+ * file and can never be elected to write for one that does, and a deck too old
48
+ * to report either keeps the behaviour it had before this rule existed. Within a
49
+ * group the lowest port wins — a fixed rule, so the same deck holds the file for
50
+ * as long as it is up and the next one inherits it as soon as that deck is gone.
51
+ *
52
+ * Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
53
+ * for the same decks over the same records, and a disagreement between them
54
+ * means one log line written twice or none at all.
55
+ */
56
+ export function electWriters(decks, platform = process.platform) {
57
+ const byLog = new Map();
58
+ for (const d of decks) {
59
+ const log = typeof d.persist === "string" ? d.persist : "";
60
+ // Two namespaces, so a deck with no log to share — and a deck too old to
61
+ // report one — is alone in its group and cannot collide with a real path.
62
+ const key = log
63
+ ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
64
+ : `deck:${d.pid}:${d.port}`;
65
+ const held = byLog.get(key);
66
+ // Ports are unique among live decks; pid only breaks a tie a stale
67
+ // discovery file could invent, so the answer stays deterministic.
68
+ if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
69
+ byLog.set(key, d);
70
+ }
71
+ }
72
+ return new Set(byLog.values());
73
+ }
74
+
75
+ /**
76
+ * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
77
+ * empty workspace is unscoped and captures every session; a rollout that never
78
+ * said where it runs is inside no workspace, so only an unscoped deck draws it.
79
+ *
80
+ * This answers two questions with one function — whether THIS deck tails a
81
+ * rollout, and whether another deck tails it too — and that is only sound while
82
+ * the rule below is the rule every deck actually runs. Model another deck's
83
+ * capture with anything else and the election covers the wrong set: a deck that
84
+ * writes without being elected, or an elected deck that never opened the file.
85
+ *
86
+ * It is also the rule hook/hook.js runs for the sessions it delivers, under the
87
+ * name capturesSession — that script is copied out of the package and run
88
+ * standalone, so the two are written twice and pinned equal by a test walking
89
+ * one table of paths through both. They were not equal: case was folded here on
90
+ * every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
91
+ * from /srv/Proj and Claude sessions from neither. Those are two real
92
+ * directories there, and the hook's own comment says what folding them together
93
+ * costs — a deck handed the events of a tree it was not scoped to. So the fold
94
+ * is per-platform on both sides now, and `--workspace` means one thing.
95
+ *
96
+ * (The narrow window that opens: two decks on Linux whose workspaces differ only
97
+ * in case, one of them old enough to still fold, both containing one rollout's
98
+ * cwd. Each models the other as tailing the file; one of them is wrong, and the
99
+ * cost is a single log line written twice.)
100
+ *
101
+ * The platform is a parameter, following the hook's cwdInWorkspace and
102
+ * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
103
+ * POSIX machine.
104
+ */
105
+ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
106
+ if (!workspace || typeof workspace !== "string") return true;
107
+ if (!cwd || typeof cwd !== "string") return false;
108
+ const p = platform === "win32" ? win32 : 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 this deck append a rollout's events to its log, or is another deck doing
120
+ * it? `decks` is every deck registered right now, `pid` identifies this one
121
+ * among them, and `cwd` is the workspace the rollout is running in.
122
+ *
123
+ * The group is every deck that tails this same rollout: each deck decides for
124
+ * itself, so all of them whose workspace contains the cwd read the file and all
125
+ * of them would write it. The hook builds the same group the same way for the
126
+ * events it delivers — it used to narrow them to the longest workspace match
127
+ * first, which is the asymmetry the predicate above describes the end of. A deck
128
+ * started with `--no-codex` tails nothing and is left out; electing it would
129
+ * mean the rollout's events reach no log at all. A deck too old to say either
130
+ * way is assumed to be tailing, which is what it was doing before this field
131
+ * existed.
132
+ */
133
+ export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
134
+ const live = Array.isArray(decks) ? decks : [];
135
+ const self = live.find(d => d && d.pid === pid) ?? null;
136
+ // No record of our own on disk — the window before the first heartbeat writes
137
+ // it, or a deck that cannot write one at all. Nobody can elect us and we
138
+ // cannot see who else is here, so keep what this deck did before the election
139
+ // existed and write. A line written twice is recoverable; a deck that quietly
140
+ // stops recording anything is not.
141
+ if (!self || typeof self.persist !== "string" || self.persist === "") return true;
142
+
143
+ const group = [self];
144
+ for (const d of live) {
145
+ if (!d || d.pid === self.pid) continue;
146
+ if (d.codex === false) continue;
147
+ if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
148
+ group.push(d);
149
+ }
150
+ return electWriters(group, platform).has(self);
151
+ }
152
+
153
+ // ─── Appending one whole line ─────────────────────────────────────────────
154
+ //
155
+ // The election above decides WHICH deck writes a line. This decides HOW, and
156
+ // the two are answers to the same question: the log is one file that several
157
+ // processes append to, so a line that arrives in pieces is a line another
158
+ // writer can land inside.
159
+ //
160
+ // What was wrong. events.jsonl was appended with `fsPromises.appendFile`, which
161
+ // is not one write(2). Node's writeFileHandle loops over the payload in chunks
162
+ // of kWriteFileMaxChunkSize — 512 KiB — awaiting each one, so any line longer
163
+ // than 524288 bytes became two or more separate appends with the event loop
164
+ // free in between. Measured here, on Node 26 / macOS 15 / APFS, by watching the
165
+ // file grow while one 5 MiB appendFile was in flight: it arrived in exactly ten
166
+ // steps of 524288 bytes. A single handle.write of the same payload arrived in
167
+ // one step of 5242880.
168
+ //
169
+ // That is reachable in ordinary use. `POST /api/event` accepts a body up to
170
+ // 5,000,000 characters, and a PostToolUse carrying a large Read or Bash
171
+ // response is routinely a good fraction of that. The event that gets spliced
172
+ // into the middle is nearly always this deck's own: pushEvent kicks off
173
+ // maybeResolveUsage / maybeResolveModel / maybeResolveContext on the same call,
174
+ // and each of those pushes its own event a moment later. Reproduced at 8/8 runs
175
+ // with one 1 MiB line and eight small ones issued in one tick, and 4/4 runs
176
+ // across two processes appending 3 MiB lines to one file.
177
+ //
178
+ // The damage is silent and doubled: the oversized event is torn into two
179
+ // unparseable halves AND the small event that landed between them is swallowed
180
+ // inside one of those halves, so a boot replay loses both.
181
+ //
182
+ // What this does instead: exactly one write(2) per line, on a descriptor opened
183
+ // O_APPEND. That is the primitive the atomicity rests on, so it is worth
184
+ // stating exactly what each platform promises.
185
+ //
186
+ // Linux — a write to a regular file holds the inode's i_rwsem for the whole
187
+ // call, so one write(2) is atomic against every other writer regardless of
188
+ // size, up to MAX_RW_COUNT (~2 GiB) after which the call returns short.
189
+ // O_APPEND additionally makes the seek-to-end and the write one step, which
190
+ // is what stops two processes overwriting each other's tail.
191
+ //
192
+ // macOS — measured, not assumed, because the guarantee is not written down as
193
+ // plainly: single writes of 1 MiB and 5 MiB in-process, and 3 MiB from two
194
+ // processes at once, produced no torn line in any run, while appendFile of
195
+ // the same payloads tore in every run.
196
+ //
197
+ // Windows — libuv issues the append as a WriteFile at the documented
198
+ // end-of-file offset on a handle opened FILE_APPEND_DATA, which NTFS serialises
199
+ // per call. This is the platform where the Codex rollout watcher is the only
200
+ // capture path there is, so it is also the platform with the most decks
201
+ // sharing one log.
202
+ //
203
+ // Where the guarantee does NOT hold: any filesystem that can return a short
204
+ // write — NFS and some network/FUSE mounts under memory pressure, and any
205
+ // platform once the payload passes its per-call ceiling. The loop below then
206
+ // issues a second write for the remainder, and another writer can land between
207
+ // them. Nothing available in userland fixes that, so the answer is the reader:
208
+ // replayLog skips a line it cannot parse, counts it, and says so, rather than
209
+ // stopping at it. A torn line costs one event, not the rest of the file.
210
+ //
211
+ // Why not cap or drop oversized events instead. The 5 MB ceiling is already the
212
+ // cap, and it is enforced where it belongs — at ingest, with a 413 the poster
213
+ // can see. Refusing to persist an event the deck is happily drawing would make
214
+ // the canvas and its own replay disagree, which is the bug this fixes wearing a
215
+ // different hat.
216
+ //
217
+ // Why the file is opened per line rather than kept open. maybeRotatePersistFile
218
+ // renames events.jsonl to events.jsonl.1 at 50 MB and `/api/clear` truncates it
219
+ // to zero; a descriptor held across either would go on filling the file nobody
220
+ // reads any more. Opening per line is also exactly the syscall count appendFile
221
+ // already paid — open, write, close — minus the extra writes.
222
+ const appendTails = new Map();
223
+
224
+ /**
225
+ * Append one already-serialized line to the shared log, whole.
226
+ *
227
+ * Appends are serialized per file behind a promise chain. That is not what
228
+ * makes them atomic — the single write(2) below is — but it keeps the order the
229
+ * lines land in equal to the order pushEvent produced them, which is the order
230
+ * a replay reads them back in, and it keeps this process to one open descriptor
231
+ * on the log no matter how many events arrive in one tick.
232
+ *
233
+ * Never rejects. This is called fire-and-forget from the hottest path in the
234
+ * process, and a rejection nobody awaits is a dead deck; a line that could not
235
+ * be written is a line lost, which the caller could not have done anything
236
+ * about anyway. The chain deliberately continues past a failure — one ENOSPC
237
+ * must not stop every later event from being recorded once space is back.
238
+ *
239
+ * @param {string} filePath absolute path to the log
240
+ * @param {string} line the line to append, INCLUDING its trailing newline
241
+ */
242
+ export function appendLogLine(filePath, line) {
243
+ const tail = (appendTails.get(filePath) ?? Promise.resolve())
244
+ .then(() => writeWholeLine(filePath, line))
245
+ .catch(() => {});
246
+ appendTails.set(filePath, tail);
247
+ // Drop the chain once it drains, so a process that writes to several logs
248
+ // over its life does not hold a promise per path it has finished with. Only
249
+ // the tail we just installed is cleared: if another append chained on in the
250
+ // meantime the map already points at that one, and deleting it would let the
251
+ // next line race the one still in flight.
252
+ tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
253
+ return tail;
254
+ }
255
+
256
+ /**
257
+ * One line, one write(2). The loop exists only for the short-write case
258
+ * described above; on every filesystem that does not do that it runs once.
259
+ *
260
+ * `position` is left null on purpose. That is what makes libuv issue write(2)
261
+ * rather than pwrite(2), and pwrite is the version that does not honour
262
+ * O_APPEND on every platform — it would write at an offset computed before the
263
+ * other writer moved the end of the file.
264
+ */
265
+ async function writeWholeLine(filePath, line) {
266
+ // Encoded up front so the short-write loop can count bytes rather than UTF-16
267
+ // units. A multi-byte character split across two chunks would otherwise be
268
+ // resumed mid-sequence and the line would be mojibake even without a race.
269
+ const buf = Buffer.from(line, "utf8");
270
+ const handle = await open(filePath, "a");
271
+ try {
272
+ let written = 0;
273
+ while (written < buf.byteLength) {
274
+ const { bytesWritten } = await handle.write(buf, written, buf.byteLength - written, null);
275
+ // A write that reports no progress would spin this loop forever inside a
276
+ // promise nobody is watching. Give up on the line instead.
277
+ if (bytesWritten <= 0) throw new Error(`append made no progress at ${written}/${buf.byteLength} bytes`);
278
+ written += bytesWritten;
279
+ }
280
+ } finally {
281
+ await handle.close();
282
+ }
283
+ }