agent-dag 1.33.93 → 1.33.94
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/bin/deck.js +5 -1
- package/dist/web/assets/{index-CaEUrDxR.js → index-DEsEm5QP.js} +1 -1
- package/dist/web/index.html +1 -1
- package/hook/hook.js +6 -0
- package/package.json +1 -1
- package/src/server/index.mjs +56 -18
- package/src/server/installer.mjs +18 -11
- package/src/server/log-writer.mjs +135 -0
package/dist/web/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>agents-deck</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DEsEm5QP.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-dwRx-2y0.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/hook/hook.js
CHANGED
|
@@ -116,6 +116,12 @@ function isAlive(pid) {
|
|
|
116
116
|
*
|
|
117
117
|
* The platform is a parameter, like cwdInWorkspace's, so the case-folding half
|
|
118
118
|
* is testable from any machine.
|
|
119
|
+
*
|
|
120
|
+
* src/server/log-writer.mjs repeats this rule for the events no hook delivers —
|
|
121
|
+
* the ones the server builds itself from Codex's rollout files — because this
|
|
122
|
+
* script is copied out of the package and cannot import it. A test compares the
|
|
123
|
+
* two directly: they decide for the same decks, and a disagreement is a line
|
|
124
|
+
* written twice or not at all.
|
|
119
125
|
*/
|
|
120
126
|
function electWriters(decks, platform = process.platform) {
|
|
121
127
|
const byLog = new Map();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.33.
|
|
3
|
+
"version": "1.33.94",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/server/index.mjs
CHANGED
|
@@ -4,12 +4,13 @@ import { createServer } from "node:http";
|
|
|
4
4
|
import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
|
|
5
5
|
import { createReadStream, existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
|
-
import { extname, join, resolve, dirname as pdirname
|
|
7
|
+
import { extname, join, resolve, dirname as pdirname } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
import { dirname } from "node:path";
|
|
10
10
|
import { createInterface } from "node:readline";
|
|
11
11
|
import { createHash, randomBytes } from "node:crypto";
|
|
12
12
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
|
+
import { codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const PKG_ROOT = resolve(__dirname, "..", "..");
|
|
@@ -778,8 +779,9 @@ function maybeResolveCodex(payload) {
|
|
|
778
779
|
// turn_context / response_item.model → model snapshot (ModelObserved on change)
|
|
779
780
|
// Events are emitted with source "codex" so pushEvent skips the Claude-only
|
|
780
781
|
// transcript enrichment (which needs transcript_path / hook events) but still
|
|
781
|
-
//
|
|
782
|
-
//
|
|
782
|
+
// broadcasts them exactly like a hook event, and persists them when this deck is
|
|
783
|
+
// the one elected to log this rollout — see writesCodexLog. This path is
|
|
784
|
+
// entirely additive — the Claude hook flow is untouched.
|
|
783
785
|
const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
|
|
784
786
|
const codexSessionModel = new Map(); // sid -> last model string
|
|
785
787
|
// How long a rollout's tail cursor is kept after it stops showing up in the
|
|
@@ -790,12 +792,32 @@ let codexScanRunning = false;
|
|
|
790
792
|
let codexWatchTimer = null;
|
|
791
793
|
let codexWorkspace = "";
|
|
792
794
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
795
|
+
/**
|
|
796
|
+
* Every deck registered right now, as its own discovery record spells it.
|
|
797
|
+
*
|
|
798
|
+
* These are the files hook.js enumerates; this is the server reading them for
|
|
799
|
+
* itself, because the rollout watcher has no hook to do it and still has to know
|
|
800
|
+
* which other decks are tailing the same file into the same log. Dead pids are
|
|
801
|
+
* ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
|
|
802
|
+
* running every 1.5s is no place to be deleting other decks' registrations.
|
|
803
|
+
*/
|
|
804
|
+
async function readLiveDecks() {
|
|
805
|
+
const dir = join(claudeConfigDir(), "agent-dag");
|
|
806
|
+
let files;
|
|
807
|
+
try { files = await readdir(dir); } catch { return []; }
|
|
808
|
+
const decks = [];
|
|
809
|
+
for (const f of files) {
|
|
810
|
+
if (!f.endsWith(".json")) continue;
|
|
811
|
+
try {
|
|
812
|
+
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
813
|
+
// Without both numbers there is nothing to group or tie-break on, so the
|
|
814
|
+
// record cannot take part in an election either way.
|
|
815
|
+
if (!d || typeof d.pid !== "number" || typeof d.port !== "number") continue;
|
|
816
|
+
if (!isProcessAlive(d.pid)) continue;
|
|
817
|
+
decks.push(d);
|
|
818
|
+
} catch { /* corrupt, or gone between listing and read */ }
|
|
819
|
+
}
|
|
820
|
+
return decks;
|
|
799
821
|
}
|
|
800
822
|
|
|
801
823
|
// List rollout files from the newest 2 day-directories. New sessions always
|
|
@@ -912,17 +934,21 @@ function parseCodexOutput(raw) {
|
|
|
912
934
|
}
|
|
913
935
|
}
|
|
914
936
|
|
|
915
|
-
|
|
916
|
-
|
|
937
|
+
// `persist` is false when another deck tailing this same rollout was elected to
|
|
938
|
+
// write it to the log they share. The event is still buffered and broadcast —
|
|
939
|
+
// every deck watching the session draws it — it is only the second copy on disk
|
|
940
|
+
// that is dropped. See writesCodexLog in log-writer.mjs.
|
|
941
|
+
function emitCodexEvent(payload, persist) {
|
|
942
|
+
pushEvent(payload, "codex", { persist });
|
|
917
943
|
}
|
|
918
944
|
|
|
919
945
|
// Emit the SessionStart root exactly once per file, lazily — only when the
|
|
920
946
|
// session actually produces an event. This keeps long-dead sessions that were
|
|
921
947
|
// merely on disk at startup from cluttering the canvas with empty roots.
|
|
922
|
-
function ensureCodexRoot(state) {
|
|
948
|
+
function ensureCodexRoot(state, persist) {
|
|
923
949
|
if (state.rootEmitted) return;
|
|
924
950
|
state.rootEmitted = true;
|
|
925
|
-
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" });
|
|
951
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
|
|
926
952
|
}
|
|
927
953
|
|
|
928
954
|
async function codexScanOnce(firstRun) {
|
|
@@ -930,6 +956,11 @@ async function codexScanOnce(firstRun) {
|
|
|
930
956
|
codexScanRunning = true;
|
|
931
957
|
try {
|
|
932
958
|
const now = Date.now();
|
|
959
|
+
// Read at most once per scan, and only from the first rollout that actually
|
|
960
|
+
// has new bytes: most ticks read nothing and must not pay a directory
|
|
961
|
+
// listing for the answer to a question nothing is asking.
|
|
962
|
+
let decksRead = null;
|
|
963
|
+
const liveDecks = () => (decksRead ??= readLiveDecks());
|
|
933
964
|
const files = await listRecentCodexRollouts();
|
|
934
965
|
for (const path of files) {
|
|
935
966
|
let st;
|
|
@@ -942,7 +973,7 @@ async function codexScanOnce(firstRun) {
|
|
|
942
973
|
// capture it. Skip files outside our workspace.
|
|
943
974
|
const header = await readCodexHeader(path);
|
|
944
975
|
if (!header || !header.sid) continue; // not ready yet — retry next tick
|
|
945
|
-
if (!codexCwdInWorkspace(header.cwd)) {
|
|
976
|
+
if (!codexCwdInWorkspace(header.cwd, codexWorkspace)) {
|
|
946
977
|
codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
|
|
947
978
|
continue;
|
|
948
979
|
}
|
|
@@ -966,6 +997,13 @@ async function codexScanOnce(firstRun) {
|
|
|
966
997
|
const consume = text.slice(0, lastNl);
|
|
967
998
|
state.offset += Buffer.byteLength(consume, "utf8") + 1; // +1 for the \n
|
|
968
999
|
|
|
1000
|
+
// Decided per file rather than per line: which decks are up, and which of
|
|
1001
|
+
// them tail this rollout, cannot change inside one batch of appended
|
|
1002
|
+
// lines, and the answer must be the same for every event in it — a root
|
|
1003
|
+
// written by one deck and its tool calls by another is worse than either.
|
|
1004
|
+
const persist = !persistPath
|
|
1005
|
+
|| writesCodexLog({ decks: await liveDecks(), pid: process.pid, cwd: state.cwd });
|
|
1006
|
+
|
|
969
1007
|
for (const line of consume.split("\n")) {
|
|
970
1008
|
if (!line) continue;
|
|
971
1009
|
let obj;
|
|
@@ -975,12 +1013,12 @@ async function codexScanOnce(firstRun) {
|
|
|
975
1013
|
// If the model changed (turn_context/response_item), surface it.
|
|
976
1014
|
const nowModel = codexSessionModel.get(state.sid);
|
|
977
1015
|
if (nowModel && nowModel !== prevModel) {
|
|
978
|
-
ensureCodexRoot(state);
|
|
979
|
-
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel });
|
|
1016
|
+
ensureCodexRoot(state, persist);
|
|
1017
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel }, persist);
|
|
980
1018
|
}
|
|
981
1019
|
if (payload) {
|
|
982
|
-
ensureCodexRoot(state);
|
|
983
|
-
emitCodexEvent(payload);
|
|
1020
|
+
ensureCodexRoot(state, persist);
|
|
1021
|
+
emitCodexEvent(payload, persist);
|
|
984
1022
|
}
|
|
985
1023
|
}
|
|
986
1024
|
}
|
package/src/server/installer.mjs
CHANGED
|
@@ -314,7 +314,7 @@ function persistField(persist) {
|
|
|
314
314
|
// ignores the mode (NTFS ACLs inherit from the profile directory, which is
|
|
315
315
|
// already per-user), and a file left over from an earlier run under a recycled
|
|
316
316
|
// pid keeps its old mode through writeFile, hence the explicit chmod.
|
|
317
|
-
export async function writeDiscovery({ port, workspace, token, persist = null }) {
|
|
317
|
+
export async function writeDiscovery({ port, workspace, token, persist = null, codex = true }) {
|
|
318
318
|
await ensureDir(AGENT_DAG_DIR);
|
|
319
319
|
const file = discoveryPath();
|
|
320
320
|
const data = {
|
|
@@ -330,6 +330,12 @@ export async function writeDiscovery({ port, workspace, token, persist = null })
|
|
|
330
330
|
// each appended their own copy to the one log they share. See
|
|
331
331
|
// electWriters in hook/hook.js.
|
|
332
332
|
persist: persistField(persist),
|
|
333
|
+
// Is this deck tailing Codex's rollout files? Those events never pass
|
|
334
|
+
// through a hook, so the decks elect a writer for them among themselves —
|
|
335
|
+
// and a deck running --no-codex must be left out of that election rather
|
|
336
|
+
// than win it and record a rollout it is not even reading. See
|
|
337
|
+
// writesCodexLog in src/server/log-writer.mjs.
|
|
338
|
+
codex: codex !== false,
|
|
333
339
|
startedAt: new Date().toISOString(),
|
|
334
340
|
};
|
|
335
341
|
await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
@@ -357,13 +363,13 @@ export function discoveryPath() {
|
|
|
357
363
|
* is cheap (one small read), so the deck checks rather than assumes.
|
|
358
364
|
*
|
|
359
365
|
* A file this process wrote is left alone, mode included. Anything else — no
|
|
360
|
-
* file, unreadable, another pid, a stale port, token
|
|
361
|
-
* replaced. Every field
|
|
362
|
-
* leave one out and a record missing it would pass as ours
|
|
363
|
-
* the log path means
|
|
364
|
-
* write their own copy of every event again.
|
|
366
|
+
* file, unreadable, another pid, a stale port, token, events log or Codex
|
|
367
|
+
* setting — is replaced. Every field another deck decides by is compared, the
|
|
368
|
+
* log path included: leave one out and a record missing it would pass as ours
|
|
369
|
+
* forever, which for the log path means no deck can tell which of them share a
|
|
370
|
+
* file and they all write their own copy of every event again.
|
|
365
371
|
*/
|
|
366
|
-
export async function ensureDiscovery({ port, workspace, token, persist = null }) {
|
|
372
|
+
export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true }) {
|
|
367
373
|
const file = discoveryPath();
|
|
368
374
|
try {
|
|
369
375
|
const d = JSON.parse(stripBom(await readFile(file, "utf8")));
|
|
@@ -372,11 +378,12 @@ export async function ensureDiscovery({ port, workspace, token, persist = null }
|
|
|
372
378
|
&& d.port === port
|
|
373
379
|
&& (d.workspace ?? "") === (workspace ?? "")
|
|
374
380
|
&& (d.token ?? "") === (token ?? "")
|
|
375
|
-
&& (d.persist ?? null) === persistField(persist)
|
|
381
|
+
&& (d.persist ?? null) === persistField(persist)
|
|
382
|
+
&& d.codex === (codex !== false)) {
|
|
376
383
|
return { file, rewritten: false };
|
|
377
384
|
}
|
|
378
385
|
} catch { /* missing, unreadable or corrupt — rewritten below */ }
|
|
379
|
-
await writeDiscovery({ port, workspace, token, persist });
|
|
386
|
+
await writeDiscovery({ port, workspace, token, persist, codex });
|
|
380
387
|
return { file, rewritten: true };
|
|
381
388
|
}
|
|
382
389
|
|
|
@@ -393,7 +400,7 @@ export async function ensureDiscovery({ port, workspace, token, persist = null }
|
|
|
393
400
|
* `stop()` must be called before the file is removed on shutdown — otherwise
|
|
394
401
|
* the next tick would put it straight back.
|
|
395
402
|
*/
|
|
396
|
-
export function keepDiscovery({ port, workspace, token, persist = null, intervalMs = 5000, onState = null } = {}) {
|
|
403
|
+
export function keepDiscovery({ port, workspace, token, persist = null, codex = true, intervalMs = 5000, onState = null } = {}) {
|
|
397
404
|
// null until the first outcome, which therefore always differs and is always
|
|
398
405
|
// reported — the caller learns where it stands before anything else happens.
|
|
399
406
|
let healthy = null;
|
|
@@ -401,7 +408,7 @@ export function keepDiscovery({ port, workspace, token, persist = null, interval
|
|
|
401
408
|
const check = async () => {
|
|
402
409
|
let state;
|
|
403
410
|
try {
|
|
404
|
-
const { rewritten } = await ensureDiscovery({ port, workspace, token, persist });
|
|
411
|
+
const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex });
|
|
405
412
|
state = { ok: true, rewritten, file: discoveryPath(), error: null };
|
|
406
413
|
} catch (err) {
|
|
407
414
|
state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
|
|
@@ -0,0 +1,135 @@
|
|
|
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 { win32, posix } from "node:path";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Does this platform's filesystem treat two spellings that differ only in case
|
|
27
|
+
* as the same file? The platform is a parameter so both answers can be checked
|
|
28
|
+
* from either kind of machine.
|
|
29
|
+
*
|
|
30
|
+
* Windows always does, and macOS does by default (APFS and HFS+ are formatted
|
|
31
|
+
* case-insensitive unless the user deliberately chose otherwise). Linux does
|
|
32
|
+
* not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
|
|
33
|
+
* /srv/A/events.jsonl are two real files, each of which needs a writer.
|
|
34
|
+
*/
|
|
35
|
+
export const foldsCase = (platform = process.platform) =>
|
|
36
|
+
platform === "win32" || platform === "darwin";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Of these decks, which ones write to disk? Returns the subset that should;
|
|
40
|
+
* every other one is expected to draw the event and keep no record of it.
|
|
41
|
+
*
|
|
42
|
+
* Decks are grouped by the log file each one names in its discovery record and
|
|
43
|
+
* one deck per group is elected. Grouping by the file rather than counting decks
|
|
44
|
+
* is what keeps the overrides honest: a deck run with `--history` sits alone in
|
|
45
|
+
* its own group and always writes, a deck run with `--no-persist` reports no
|
|
46
|
+
* file and can never be elected to write for one that does, and a deck too old
|
|
47
|
+
* to report either keeps the behaviour it had before this rule existed. Within a
|
|
48
|
+
* group the lowest port wins — a fixed rule, so the same deck holds the file for
|
|
49
|
+
* as long as it is up and the next one inherits it as soon as that deck is gone.
|
|
50
|
+
*
|
|
51
|
+
* Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
|
|
52
|
+
* for the same decks over the same records, and a disagreement between them
|
|
53
|
+
* means one log line written twice or none at all.
|
|
54
|
+
*/
|
|
55
|
+
export function electWriters(decks, platform = process.platform) {
|
|
56
|
+
const byLog = new Map();
|
|
57
|
+
for (const d of decks) {
|
|
58
|
+
const log = typeof d.persist === "string" ? d.persist : "";
|
|
59
|
+
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
60
|
+
// report one — is alone in its group and cannot collide with a real path.
|
|
61
|
+
const key = log
|
|
62
|
+
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
63
|
+
: `deck:${d.pid}:${d.port}`;
|
|
64
|
+
const held = byLog.get(key);
|
|
65
|
+
// Ports are unique among live decks; pid only breaks a tie a stale
|
|
66
|
+
// discovery file could invent, so the answer stays deterministic.
|
|
67
|
+
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
68
|
+
byLog.set(key, d);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return new Set(byLog.values());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
|
|
76
|
+
* empty workspace is unscoped and captures every session.
|
|
77
|
+
*
|
|
78
|
+
* Case is folded on every platform here, which the hook's own cwdInWorkspace
|
|
79
|
+
* deliberately does not do. That stays as it was, because this predicate now
|
|
80
|
+
* answers two questions with one function: whether THIS deck tails a rollout,
|
|
81
|
+
* and whether another deck tails it too. Model the other deck's capture with a
|
|
82
|
+
* rule stricter than the one it actually runs and the election covers the wrong
|
|
83
|
+
* set — a deck that writes without being elected, or an elected deck that never
|
|
84
|
+
* saw the file. Being wrong in the over-matching direction on Linux costs a deck
|
|
85
|
+
* a sibling tree it was not scoped to; being wrong in either direction here
|
|
86
|
+
* costs a duplicated log line or a lost one.
|
|
87
|
+
*
|
|
88
|
+
* The platform is a parameter, following the hook's cwdInWorkspace and
|
|
89
|
+
* spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
|
|
90
|
+
* POSIX machine.
|
|
91
|
+
*/
|
|
92
|
+
export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
93
|
+
if (!workspace || typeof workspace !== "string") return true;
|
|
94
|
+
if (!cwd || typeof cwd !== "string") return false;
|
|
95
|
+
const p = platform === "win32" ? win32 : posix;
|
|
96
|
+
const a = p.resolve(cwd).toLowerCase();
|
|
97
|
+
const b = p.resolve(workspace).toLowerCase();
|
|
98
|
+
if (a === b) return true;
|
|
99
|
+
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
100
|
+
// would match nothing.
|
|
101
|
+
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Does this deck append a rollout's events to its log, or is another deck doing
|
|
106
|
+
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
107
|
+
* among them, and `cwd` is the workspace the rollout is running in.
|
|
108
|
+
*
|
|
109
|
+
* The group is every deck that tails this same rollout — the hook's targets are
|
|
110
|
+
* narrowed to the longest workspace match, but nothing narrows these: each deck
|
|
111
|
+
* decides for itself, so all of them whose workspace contains the cwd read the
|
|
112
|
+
* file and all of them would write it. A deck started with `--no-codex` tails
|
|
113
|
+
* nothing and is left out; electing it would mean the rollout's events reach no
|
|
114
|
+
* log at all. A deck too old to say either way is assumed to be tailing, which
|
|
115
|
+
* is what it was doing before this field existed.
|
|
116
|
+
*/
|
|
117
|
+
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
118
|
+
const live = Array.isArray(decks) ? decks : [];
|
|
119
|
+
const self = live.find(d => d && d.pid === pid) ?? null;
|
|
120
|
+
// No record of our own on disk — the window before the first heartbeat writes
|
|
121
|
+
// it, or a deck that cannot write one at all. Nobody can elect us and we
|
|
122
|
+
// cannot see who else is here, so keep what this deck did before the election
|
|
123
|
+
// existed and write. A line written twice is recoverable; a deck that quietly
|
|
124
|
+
// stops recording anything is not.
|
|
125
|
+
if (!self || typeof self.persist !== "string" || self.persist === "") return true;
|
|
126
|
+
|
|
127
|
+
const group = [self];
|
|
128
|
+
for (const d of live) {
|
|
129
|
+
if (!d || d.pid === self.pid) continue;
|
|
130
|
+
if (d.codex === false) continue;
|
|
131
|
+
if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
|
|
132
|
+
group.push(d);
|
|
133
|
+
}
|
|
134
|
+
return electWriters(group, platform).has(self);
|
|
135
|
+
}
|