agent-dag 1.35.13 → 1.35.15

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.
@@ -40,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-BIOf9r96.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-CdgG2b5Q.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-hLBidJXz.css">
45
45
  </head>
46
46
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.35.13",
3
+ "version": "1.35.15",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,7 @@ import { dirname } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
11
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12
12
  import { claudeConfigDir } from "./claude-dir.mjs";
13
- import { CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
13
+ import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
14
14
  import { PRODUCT } from "./brand.mjs";
15
15
  import { invokedName, renameNotice } from "./invoked-as.mjs";
16
16
  import { codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
@@ -550,38 +550,79 @@ export function ccProjectSlug(cwd) {
550
550
  return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
551
551
  }
552
552
 
553
- async function scanClaudeMdFiles(cwd) {
554
- if (!cwd || typeof cwd !== "string") return [];
553
+ /**
554
+ * Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
555
+ *
556
+ * Both CLIs load their memory file the same way — nearest-first from the
557
+ * working directory outwards — and differ only in what the file is CALLED and
558
+ * in what else they add on top, so the walk is written once here and the two
559
+ * scanners below supply their own names. `rels` is a list of paths RELATIVE to
560
+ * each directory on the walk rather than bare filenames, because CC also honours
561
+ * `.claude/CLAUDE.md` at every level and Codex does not.
562
+ *
563
+ * Sixteen levels is the same depth this has always used: deep enough for any
564
+ * real checkout, shallow enough that a cwd on a network mount cannot turn one
565
+ * context read into an unbounded number of stat() calls.
566
+ *
567
+ * Returns paths without touching the disk. Statting them is collectMemoryFiles'
568
+ * job, so a caller that wants to add its own paths — a user-global file, a
569
+ * per-project memory directory — can splice them into one ordered list and get
570
+ * a single de-duplicated, existence-checked answer back.
571
+ */
572
+ function memoryWalkPaths(cwd, rels) {
573
+ const out = [];
574
+ let dir = resolve(cwd);
575
+ for (let depth = 0; depth < 16; depth++) {
576
+ for (const rel of rels) out.push(join(dir, rel));
577
+ const parent = pdirname(dir);
578
+ if (parent === dir) break;
579
+ dir = parent;
580
+ }
581
+ return out;
582
+ }
583
+
584
+ /**
585
+ * Which of `paths` are real, non-empty files, in the order given and with
586
+ * duplicates dropped.
587
+ *
588
+ * A zero-byte file is skipped on purpose: it contributes nothing to the model's
589
+ * context, and listing it in the modal would have the reader looking for the
590
+ * bytes it claims to cost. A path that cannot be stat()ed is simply absent —
591
+ * this runs against a tree another process is editing, and a permissions error
592
+ * on one candidate is no reason to lose the other fifteen.
593
+ */
594
+ async function collectMemoryFiles(paths) {
555
595
  const found = [];
556
596
  const seen = new Set();
557
- const home = homedir();
558
- const push = async (p) => {
559
- if (seen.has(p)) return;
597
+ for (const p of paths) {
598
+ if (seen.has(p)) continue;
560
599
  seen.add(p);
561
600
  try {
562
601
  const s = await stat(p);
563
602
  if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
564
603
  } catch {}
565
- };
566
- // Walk up from cwd to filesystem root. At each dir, check for the
567
- // canonical CC memory filenames plus CLAUDE.local.md (user-private).
568
- let dir = resolve(cwd);
569
- for (let depth = 0; depth < 16; depth++) {
570
- for (const rel of [
571
- "CLAUDE.md",
572
- "CLAUDE.local.md",
573
- join(".claude", "CLAUDE.md"),
574
- join(".claude", "CLAUDE.local.md"),
575
- ]) {
576
- await push(join(dir, rel));
577
- }
578
- const parent = pdirname(dir);
579
- if (parent === dir) break;
580
- dir = parent;
581
604
  }
605
+ return found;
606
+ }
607
+
608
+ /** The memory files a CLAUDE session has in scope. Exported alongside its Codex
609
+ * counterpart below so a test can check the pair together — the two must agree
610
+ * on the walk and disagree on the filename, and only one of them showing up in
611
+ * a test is how they would drift. */
612
+ export async function scanClaudeMdFiles(cwd) {
613
+ if (!cwd || typeof cwd !== "string") return [];
614
+ const home = homedir();
615
+ // Walk up from cwd to filesystem root, checking the canonical CC memory
616
+ // filenames plus CLAUDE.local.md (user-private) at each level.
617
+ const paths = memoryWalkPaths(cwd, [
618
+ "CLAUDE.md",
619
+ "CLAUDE.local.md",
620
+ join(".claude", "CLAUDE.md"),
621
+ join(".claude", "CLAUDE.local.md"),
622
+ ]);
582
623
  // User-global memory.
583
- await push(join(home, ".claude", "CLAUDE.md"));
584
- await push(join(home, ".claude", "CLAUDE.local.md"));
624
+ paths.push(join(home, ".claude", "CLAUDE.md"));
625
+ paths.push(join(home, ".claude", "CLAUDE.local.md"));
585
626
  // Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
586
627
  // (plus MEMORY.md index). CC injects these into context for sessions
587
628
  // whose cwd matches the slug.
@@ -591,11 +632,54 @@ async function scanClaudeMdFiles(cwd) {
591
632
  try {
592
633
  const entries = await readdir(memDir);
593
634
  for (const f of entries) {
594
- if (f.toLowerCase().endsWith(".md")) await push(join(memDir, f));
635
+ if (f.toLowerCase().endsWith(".md")) paths.push(join(memDir, f));
595
636
  }
596
637
  } catch {}
597
638
  }
598
- return found;
639
+ return collectMemoryFiles(paths);
640
+ }
641
+
642
+ /**
643
+ * The memory files a CODEX session has in scope: AGENTS.md, not CLAUDE.md.
644
+ *
645
+ * WHY THIS FUNCTION EXISTS AT ALL (#399). The context modal's third section was
646
+ * fed by scanClaudeMdFiles for every session regardless of provider, so the
647
+ * moment the donut became reachable for Codex the modal would have told a Codex
648
+ * user "No CLAUDE.md files found on the path from cwd to ~/.claude" — naming a
649
+ * file and a directory Codex does not read. Before this, `AGENTS.md` did not
650
+ * appear anywhere in this repository.
651
+ *
652
+ * That Codex reads it is not an assumption. Sampled every rollout under this
653
+ * machine's CODEX_HOME (structural search, no record content printed): the
654
+ * literal string `AGENTS.md` appears on 9 lines across 5 of the 8 files — in the
655
+ * `response_item/message` role=user preamble Codex prepends to a turn, in a
656
+ * role=developer message, and in a `world_state` record — while `CLAUDE.md`
657
+ * appears on zero lines in any of them.
658
+ *
659
+ * WHY THE FILESYSTEM AND NOT THE ROLLOUT. The rollout does name the files, but
660
+ * only inside message TEXT, and reading the text of a user's conversation to
661
+ * find a filename is not a trade this deck makes anywhere else — the Claude side
662
+ * has always answered the same question by walking the filesystem, and the two
663
+ * halves of one modal section should be derived the same way or the reader
664
+ * cannot compare them.
665
+ *
666
+ * CODEX_HOME comes from codex-dir.mjs like every other Codex path in the
667
+ * process (#375), so a relocated Codex home is honoured here without this
668
+ * module growing a sixth spelling of the rule.
669
+ *
670
+ * Exported for the tests, like readContextFromTranscript beside it: the rule for
671
+ * which files a session has in scope is worth pinning directly, rather than
672
+ * through a watcher, a temp home and a 1.5s poll.
673
+ */
674
+ export async function scanAgentsMdFiles(cwd) {
675
+ if (!cwd || typeof cwd !== "string") return [];
676
+ // Codex has no `.codex/AGENTS.md` per-directory convention to mirror CC's
677
+ // `.claude/CLAUDE.md`, so the per-level list is the single filename.
678
+ const paths = memoryWalkPaths(cwd, ["AGENTS.md"]);
679
+ // The user-global instructions file, which Codex loads for every session
680
+ // whatever the cwd — the counterpart of ~/.claude/CLAUDE.md.
681
+ paths.push(join(CODEX_HOME, "AGENTS.md"));
682
+ return collectMemoryFiles(paths);
599
683
  }
600
684
 
601
685
  function maybeResolveContext(payload) {
@@ -611,14 +695,14 @@ function maybeResolveContext(payload) {
611
695
  lastContextReadAt.set(sid, now);
612
696
  pendingContextReads.add(sid);
613
697
  Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
614
- .then(([breakdown, claudeMdFiles]) => {
615
- if (!breakdown && (!claudeMdFiles || claudeMdFiles.length === 0)) return;
698
+ .then(([breakdown, memoryFiles]) => {
699
+ if (!breakdown && (!memoryFiles || memoryFiles.length === 0)) return;
616
700
  pushEvent({
617
701
  hook_event_name: "ContextObserved",
618
702
  session_id: sid,
619
703
  context: {
620
704
  ...(breakdown ?? {}),
621
- claudeMdFiles: claudeMdFiles ?? [],
705
+ memoryFiles: memoryFiles ?? [],
622
706
  },
623
707
  }, "internal");
624
708
  })
@@ -626,6 +710,61 @@ function maybeResolveContext(payload) {
626
710
  .finally(() => pendingContextReads.delete(sid));
627
711
  }
628
712
 
713
+ // Throttle state for the Codex half of the same question. Separate maps rather
714
+ // than sharing maybeResolveContext's, because the two run on different triggers
715
+ // — a hook payload there, a batch of appended rollout lines here — and one
716
+ // session cannot be both.
717
+ const lastCodexMemoryReadAt = new Map();
718
+ const pendingCodexMemoryReads = new Set();
719
+
720
+ /**
721
+ * Emit the memory files a Codex session has in scope, throttled per session.
722
+ *
723
+ * WHY THIS IS NOT maybeResolveContext (#399). That function early-returns
724
+ * without `payload.transcript_path`, a field only Claude Code sends, and it
725
+ * would scan for CLAUDE.md if it got that far. It is also unreachable from here
726
+ * on a second count: pushEvent gates all of its enrichment on `source ===
727
+ * "hook"`, and the Codex rollout watcher emits with source "codex" because it is
728
+ * not a hook stream at all. So this is called from the watcher's own scan loop,
729
+ * next to the lazy root, rather than off the back of an event.
730
+ *
731
+ * There is no structural breakdown alongside the file list, and that is the
732
+ * honest answer rather than a gap: the counts the Claude side reports —
733
+ * user/assistant messages, tool uses, tool results, system-reminders — come from
734
+ * a regex scan of a transcript this deck has read from byte zero, and the
735
+ * watcher deliberately SKIPS a pre-existing session's history at startup
736
+ * (`state.offset = st.size`). Counting from the moment the deck attached would
737
+ * produce five confident numbers that are all short by however much of the
738
+ * session happened first, on a panel whose whole purpose is to say what is in
739
+ * the window. ContextModal says so in the slot those counts would have used;
740
+ * see codex-approval.ts for the same move on a different unanswerable question.
741
+ */
742
+ function maybeResolveCodexMemory(sid, cwd) {
743
+ if (!sid || !cwd) return;
744
+ if (pendingCodexMemoryReads.has(sid)) return;
745
+ const now = Date.now();
746
+ const last = lastCodexMemoryReadAt.get(sid) ?? 0;
747
+ if (now - last < CONTEXT_READ_THROTTLE_MS) return;
748
+ lastCodexMemoryReadAt.set(sid, now);
749
+ pendingCodexMemoryReads.add(sid);
750
+ scanAgentsMdFiles(cwd)
751
+ .then(memoryFiles => {
752
+ // Nothing found is not a fact worth an event: the reducer merges a
753
+ // ContextObserved into whatever the session already had, and an empty list
754
+ // would only ever overwrite a real one with nothing. A repo that grows its
755
+ // first AGENTS.md mid-session is picked up by the next throttled pass.
756
+ if (!memoryFiles.length) return;
757
+ pushEvent({
758
+ hook_event_name: "ContextObserved",
759
+ session_id: sid,
760
+ provider: "codex",
761
+ context: { memoryFiles },
762
+ }, "internal");
763
+ })
764
+ .catch(() => {})
765
+ .finally(() => pendingCodexMemoryReads.delete(sid));
766
+ }
767
+
629
768
  // ─── Codex transcript enrichment ──────────────────────────────────────────
630
769
  // Codex CLI hook payloads carry `session_id` but no transcript path. Sessions
631
770
  // are persisted to ~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl with one
@@ -777,7 +916,9 @@ function maybeResolveCodex(payload) {
777
916
  // response_item/function_call_output → PostToolUse / PostToolUseFailure,
778
917
  // decided by the outcome line Codex
779
918
  // prepends to the output (codexCallFailed)
780
- // event_msg/token_count → UsageObserved
919
+ // event_msg/token_count → UsageObserved, and riding on it the
920
+ // session's live context occupancy and
921
+ // the CLI's own window (#399)
781
922
  // event_msg/task_started (+window) → ModelObserved (context window)
782
923
  // event_msg/task_complete → Stop (the turn finished)
783
924
  // event_msg/turn_aborted → Stop (the turn was interrupted)
@@ -970,8 +1111,68 @@ export function codexObjToPayload(obj, sid, cwd) {
970
1111
  if (pl.type === "item_completed" && pl.item && pl.item.type === "UserMessage") {
971
1112
  return { ...base, hook_event_name: "UserPromptSubmit", prompt: codexItemText(pl.item), model };
972
1113
  }
973
- if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
974
- return { ...base, hook_event_name: "UsageObserved", usage: pl.info.total_token_usage, model };
1114
+ // Codex states THREE numbers on every `token_count` and this branch used to
1115
+ // take one of them (#399). The other two are what the context donut is drawn
1116
+ // from, so the deck rendered no context readout at all for the only provider
1117
+ // that reports its window exactly.
1118
+ //
1119
+ // Measured across every rollout under this machine's CODEX_HOME — 178
1120
+ // `token_count` records, 164 on Codex 0.144.5 and 14 on 0.147.0 — all 178
1121
+ // carry `info.last_token_usage`, `info.total_token_usage` and
1122
+ // `info.model_context_window`. Nothing here is a new read: the line is
1123
+ // already parsed and the object already destructured.
1124
+ //
1125
+ // total_token_usage cumulative SPEND for the session. Every request's
1126
+ // prompt summed, so it counts the cached prefix again
1127
+ // on every turn and passes the context window many
1128
+ // times over inside one session (5,238,700 against a
1129
+ // 258,400 window in the longest file here). Correct
1130
+ // for cost, meaningless as an occupancy figure.
1131
+ //
1132
+ // last_token_usage the MOST RECENT request: `input_tokens` is the whole
1133
+ // conversation Codex sent (it already contains the
1134
+ // cached prefix — see billedInputTokens), plus that
1135
+ // request's completion. `total_tokens` is exactly
1136
+ // input + output on 177 of the 178 records.
1137
+ //
1138
+ // model_context_window the CLI's own ceiling, 258,400 for gpt-5.6 against
1139
+ // the 1,050,000 the static table guesses.
1140
+ //
1141
+ // WHY `last_token_usage.total_tokens` AND NOT `input_tokens`. The prompt-only
1142
+ // figure is the closer analogue of the Claude side, which sums the last usage
1143
+ // block's input + cache_read + cache_creation and leaves the completion out.
1144
+ // The difference is one response — 20 to 2,288 tokens in this sample, under
1145
+ // 1% of the window — and `total_tokens` wins on the case where they diverge
1146
+ // for real: on `thread_rolled_back` (the user rewinding the conversation)
1147
+ // Codex writes a `token_count` whose per-request components are all zero and
1148
+ // whose `last_token_usage.total_tokens` is the RECOMPUTED context size —
1149
+ // 47,355, down from 58,516 — while `total_token_usage` does not move at all,
1150
+ // because no request was made. Codex is using that field as "tokens in the
1151
+ // window", and reading `input_tokens` there would collapse the donut to 0%
1152
+ // at precisely the moment the number changed most.
1153
+ //
1154
+ // The window rides along too. `task_started` below is the only other carrier
1155
+ // and it fires once per turn, so a deck that attached mid-turn — the ordinary
1156
+ // case, since the watcher skips a pre-existing session's history at startup —
1157
+ // had to wait for the next turn before the donut could be scaled against
1158
+ // anything but the wrong static default.
1159
+ if (pl.type === "token_count" && pl.info) {
1160
+ const info = pl.info;
1161
+ const last = info.last_token_usage;
1162
+ const contextTokens = last && typeof last.total_tokens === "number" ? last.total_tokens : undefined;
1163
+ const window = typeof info.model_context_window === "number" ? info.model_context_window : undefined;
1164
+ // A record that states none of the three says nothing, and emitting an
1165
+ // event for it would put an empty envelope in the ring buffer and in the
1166
+ // persisted log for every reader to skip forever.
1167
+ if (!info.total_token_usage && contextTokens === undefined && window === undefined) return null;
1168
+ return {
1169
+ ...base,
1170
+ hook_event_name: "UsageObserved",
1171
+ usage: info.total_token_usage,
1172
+ model,
1173
+ model_context_window: window,
1174
+ context_tokens: contextTokens,
1175
+ };
975
1176
  }
976
1177
  if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
977
1178
  return { ...base, hook_event_name: "ModelObserved", model, model_context_window: pl.model_context_window };
@@ -1244,6 +1445,14 @@ async function codexScanOnce(firstRun) {
1244
1445
  emitCodexEvent(payload, persist);
1245
1446
  }
1246
1447
  }
1448
+
1449
+ // Once per batch of appended lines rather than once per line — the scan
1450
+ // throttles itself per session, but the cheapest call is the one that is
1451
+ // never made, and a batch can be hundreds of lines. Gated on the root
1452
+ // existing so a session the deck has decided not to draw does not cost a
1453
+ // directory walk, and repeated rather than done once at root creation so
1454
+ // an AGENTS.md written after the session started is still found (#399).
1455
+ if (state.rootEmitted) maybeResolveCodexMemory(state.sid, state.cwd);
1247
1456
  }
1248
1457
 
1249
1458
  // Rollout files fall out of the newest-2-days listing and never come back,
@@ -1308,6 +1517,7 @@ function forgetSession(sid) {
1308
1517
  lastContextReadAt.delete(sid);
1309
1518
  codexRolloutPathBySid.delete(sid);
1310
1519
  lastCodexUsageReadAt.delete(sid);
1520
+ lastCodexMemoryReadAt.delete(sid);
1311
1521
  codexSessionModel.delete(sid);
1312
1522
  codexSessionApproval.delete(sid);
1313
1523
  }
@@ -10,6 +10,17 @@
10
10
  // Only ever touches its own entry, tagged `__agent-dag-sound`. Hooks the user
11
11
  // wrote themselves are left exactly as found — including the platform-specific
12
12
  // ones this replaces, which are reported rather than deleted.
13
+ //
14
+ // Claude Code only, and deliberately: everything here is one entry in Claude
15
+ // Code's settings.json, which Claude Code alone reads and executes. There is no
16
+ // Codex equivalent — the deck installs no Codex hooks and tails the rollout
17
+ // files instead — so a Codex turn ends in silence and no amount of writing to
18
+ // this file changes that. The browser is where that is said rather than
19
+ // guessed: the topbar button is drawn only where Claude Code is, and its
20
+ // tooltip names the limit and the mechanism behind it. If this module ever does
21
+ // learn a second provider, src/web/provider-copy.ts's finishSoundTitle is the
22
+ // sentence that has to move with it, and finish-sound-scope.test.ts fails until
23
+ // it does (#394).
13
24
  import { readFile, mkdir } from "node:fs/promises";
14
25
  import { existsSync } from "node:fs";
15
26
  import { join, dirname } from "node:path";