ccdeck 3.29.11 → 3.31.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.
- package/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// What the deck learns about a session that its hooks never say: the model,
|
|
2
|
+
// the token spend, the session's name and recap, and the context it is
|
|
3
|
+
// carrying — each read off the session's transcript (or, for a Codex session's
|
|
4
|
+
// memory files, off its working tree) and sent on as a synthetic event.
|
|
5
|
+
//
|
|
6
|
+
// This lived in src/server/index.mjs, between the log rotation and the Codex
|
|
7
|
+
// rollout reader. pushEvent asks it to look at every live hook event, and it
|
|
8
|
+
// answers through pushEvent, which it reaches through event-sink.mjs so that
|
|
9
|
+
// the two need not import each other. Its per-session caches are its own:
|
|
10
|
+
// pushEvent asks knownModelId for the stamp, forgetSession
|
|
11
|
+
// (session-tracking.mjs) calls forgetEnrichment when it forgets a session, and
|
|
12
|
+
// handleClear (clear-route.mjs) calls clearEnrichmentGates on a Clear. The
|
|
13
|
+
// readers, the throttles and the emits are unchanged.
|
|
14
|
+
import { readdir } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
// Which memory files a session has in scope, CLAUDE.md or AGENTS.md — see
|
|
17
|
+
// memory-files.mjs.
|
|
18
|
+
import { scanAgentsMdFiles, scanClaudeMdFiles } from "./memory-files.mjs";
|
|
19
|
+
// Claude Code's "※ recap:" line — see session-recap.mjs.
|
|
20
|
+
import { RECAP_MARK } from "./session-recap.mjs";
|
|
21
|
+
// The shared transcript cursor every pass below reads through — see
|
|
22
|
+
// transcript-scan.mjs.
|
|
23
|
+
import { hasSpend, mergeUsageByModel, newUsageTotals, scanTranscript } from "./transcript-scan.mjs";
|
|
24
|
+
// event-pipeline.mjs's pushEvent, reached without importing it — see
|
|
25
|
+
// event-sink.mjs.
|
|
26
|
+
import { pushEvent } from "./event-sink.mjs";
|
|
27
|
+
// One read per session at a time, and none inside the window — the gate each
|
|
28
|
+
// pass below keeps. See session-read-gate.mjs.
|
|
29
|
+
import { sessionReadGate } from "./session-read-gate.mjs";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The session a hook payload is about and the transcript it names, or null when
|
|
33
|
+
* it names either one not at all. The model, usage, naming and context passes
|
|
34
|
+
* below each begin here, because each reads that transcript for that session
|
|
35
|
+
* and has nothing to do without both; it was the same four lines at the top of
|
|
36
|
+
* all four.
|
|
37
|
+
*
|
|
38
|
+
* Pure and exported for its test. The passes reduce it to "a read was
|
|
39
|
+
* scheduled or it was not", which a test can only watch through a throttle and
|
|
40
|
+
* a filesystem.
|
|
41
|
+
*/
|
|
42
|
+
export function transcriptTarget(payload) {
|
|
43
|
+
if (!payload || typeof payload !== "object") return null;
|
|
44
|
+
const sid = payload.session_id;
|
|
45
|
+
const tp = payload.transcript_path;
|
|
46
|
+
if (!sid || !tp) return null;
|
|
47
|
+
return { sid, tp };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Model enrichment ────────────────────────────────────────────────────
|
|
51
|
+
// CC's hook payloads never carry the `model` field — but every hook
|
|
52
|
+
// references a `transcript_path` JSONL that contains lines like
|
|
53
|
+
// `"model":"claude-opus-4-7"`. We re-read the tail of that file on every
|
|
54
|
+
// event for the session (throttled to MODEL_READ_THROTTLE_MS), cache the
|
|
55
|
+
// resolved root + subagent models, and (a) inject `model` into subsequent
|
|
56
|
+
// payloads for that session before broadcasting, (b) emit a synthetic
|
|
57
|
+
// `ModelObserved` event whenever that set CHANGES, so the client backfills
|
|
58
|
+
// agents created before the model was resolved. The cache is the emit
|
|
59
|
+
// filter, not a read filter: reading once per session meant a subagent
|
|
60
|
+
// model that only appears after the root is known was never picked up.
|
|
61
|
+
const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
|
|
62
|
+
const MODEL_READ_THROTTLE_MS = 2500;
|
|
63
|
+
const modelReads = sessionReadGate(MODEL_READ_THROTTLE_MS);
|
|
64
|
+
|
|
65
|
+
/** The cache entry is `{ rootModel, subsSig }`, but `payload.model` is a
|
|
66
|
+
* model *string* — that is the only shape the client's recursive scanner
|
|
67
|
+
* reads. Returns the root model id, or null when the session resolved
|
|
68
|
+
* only subagent models and has no root model yet. */
|
|
69
|
+
export function cachedModelId(cached) {
|
|
70
|
+
const rootModel = cached?.rootModel;
|
|
71
|
+
return typeof rootModel === "string" && rootModel ? rootModel : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The root model already resolved for this session, or null — what pushEvent
|
|
75
|
+
* stamps on a payload that arrives without one. */
|
|
76
|
+
function knownModelId(sid) {
|
|
77
|
+
return cachedModelId(modelBySession.get(sid));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read the main session JSONL. Returns the root model and any
|
|
81
|
+
* legacy-schema subagent models (older CC versions kept subagent blocks
|
|
82
|
+
* inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
|
|
83
|
+
* store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
|
|
84
|
+
* handled by `readSubagentsFromDir` below. */
|
|
85
|
+
export async function readModelFromTranscript(path) {
|
|
86
|
+
const state = await scanTranscript(path);
|
|
87
|
+
if (!state) return null;
|
|
88
|
+
const rootModel = state.rootModel ?? state.lastModel;
|
|
89
|
+
const subagentModels = { ...state.subagentModels };
|
|
90
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return null;
|
|
91
|
+
return { rootModel, subagentModels };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
|
|
95
|
+
* `<projects>/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl` with a
|
|
96
|
+
* sidecar `.meta.json` carrying `{agentType, description}`. The hook
|
|
97
|
+
* payload's `agent_id` matches the file's <agentId>, so the reducer can
|
|
98
|
+
* attribute via the existing `subagentModels` map (it keys by parentToolUseId
|
|
99
|
+
* but the reducer looks up `${sessionId}::${key}` and the subagent node id
|
|
100
|
+
* is built from `agent_id` — identical lookup either way).
|
|
101
|
+
*
|
|
102
|
+
* Returns `{ models: { [agentId]: model }, usage: <summed totals|null> }` over
|
|
103
|
+
* every agent-*.jsonl in the directory, or null when there is no such
|
|
104
|
+
* directory — which is every Codex session and every legacy-schema Claude one.
|
|
105
|
+
*
|
|
106
|
+
* WHY IT RETURNS USAGE AND NOT ONLY MODELS (#685). `scanTranscript` folds a
|
|
107
|
+
* file's usage totals whether or not anyone asks for them, so this walk has
|
|
108
|
+
* been computing every subagent's spend and dropping it on the floor, while
|
|
109
|
+
* the session's totals came from the main transcript alone — and the main
|
|
110
|
+
* transcript does not restate a subagent's turns. Measured on one real
|
|
111
|
+
* session on the reporter's machine: 40.4M cache-read tokens in the main
|
|
112
|
+
* JSONL against 217.7M across its twenty subagent files, so the deck was
|
|
113
|
+
* reporting about a sixth of what the session actually cost.
|
|
114
|
+
*
|
|
115
|
+
* WHY THE TOTALS ARE SUMMED HERE RATHER THAN SHIPPED PER AGENT. The obvious
|
|
116
|
+
* shape is a `{ [agentId]: totals }` map so each subagent card can price its
|
|
117
|
+
* own spend, and the cost of that shape is set by CC and not by us: #611
|
|
118
|
+
* measured live sessions holding 198 and 133 of these files, and this machine
|
|
119
|
+
* has one holding 397. At ~200 bytes an entry on a 2.5 s cadence that is
|
|
120
|
+
* ~80 KB per pass into the SSE fan-out, the event ring and events.jsonl —
|
|
121
|
+
* ~115 MB an hour into a log that rotates at 50 MB. One summed object is
|
|
122
|
+
* ~150 bytes whatever the session delegates, so the session total is exact
|
|
123
|
+
* and constant-cost, and per-card attribution stays a separate question.
|
|
124
|
+
*
|
|
125
|
+
* `usageByModel` is summed on the same terms and for the same reason (#686):
|
|
126
|
+
* a subagent runs on whatever model its Task was given, which is routinely not
|
|
127
|
+
* its parent's — the deck already reads a per-agent `models` map three lines
|
|
128
|
+
* up precisely because of that — so folding delegated tokens into the session
|
|
129
|
+
* total without their model would price a Haiku subagent's spend at the root's
|
|
130
|
+
* Opus rate. It stays constant-cost the way the flat total does: the key count
|
|
131
|
+
* is the number of MODELS a session touched, two or three, not the number of
|
|
132
|
+
* files it delegated to. */
|
|
133
|
+
async function readSubagentsFromDir(transcriptPath) {
|
|
134
|
+
// Subagent dir sits next to the main jsonl: <dir>/<sessionId>/subagents/
|
|
135
|
+
// Derive from transcript_path by stripping the .jsonl suffix.
|
|
136
|
+
if (!transcriptPath || typeof transcriptPath !== "string") return null;
|
|
137
|
+
const sessionDir = transcriptPath.replace(/\.jsonl$/i, "");
|
|
138
|
+
const subDir = join(sessionDir, "subagents");
|
|
139
|
+
let entries;
|
|
140
|
+
try { entries = await readdir(subDir); } catch { return null; }
|
|
141
|
+
const models = {};
|
|
142
|
+
const usage = newUsageTotals();
|
|
143
|
+
const usageByModel = {};
|
|
144
|
+
let spent = false;
|
|
145
|
+
for (const f of entries) {
|
|
146
|
+
if (!/^agent-([0-9a-f]+)\.jsonl$/i.test(f)) continue;
|
|
147
|
+
const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
|
|
148
|
+
const full = join(subDir, f);
|
|
149
|
+
try {
|
|
150
|
+
// Same incremental cursor as the main transcript. Last-seen claude-*
|
|
151
|
+
// model wins — subagents may switch model mid-turn (Sonnet → Haiku for
|
|
152
|
+
// tool-call fallback etc.).
|
|
153
|
+
const state = await scanTranscript(full);
|
|
154
|
+
if (!state) continue;
|
|
155
|
+
if (state.lastModel) models[agentId] = state.lastModel;
|
|
156
|
+
// The cursor makes this cumulative and idempotent: `state.usage` is the
|
|
157
|
+
// whole file's totals however many passes it took to fold them, so
|
|
158
|
+
// re-summing every pass restates the same number rather than growing it.
|
|
159
|
+
for (const k of Object.keys(usage)) usage[k] += state.usage[k];
|
|
160
|
+
// Idempotent on the same argument as the line above: each file's split is
|
|
161
|
+
// its own cumulative totals, so re-summing every pass restates the map
|
|
162
|
+
// rather than growing it.
|
|
163
|
+
mergeUsageByModel(usageByModel, state.usageByModel);
|
|
164
|
+
if (hasSpend(state.usage)) spent = true;
|
|
165
|
+
} catch { /* skip unreadable file */ }
|
|
166
|
+
}
|
|
167
|
+
const hasModels = Object.keys(models).length > 0;
|
|
168
|
+
if (!hasModels && !spent) return null;
|
|
169
|
+
return {
|
|
170
|
+
models: hasModels ? models : null,
|
|
171
|
+
usage: spent ? usage : null,
|
|
172
|
+
usageByModel: spent ? usageByModel : null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// One walk of `<sessionDir>/subagents/` serves both the model pass and the
|
|
177
|
+
// usage pass. The two are throttled independently but fire from the same hook
|
|
178
|
+
// events, so without this the directory — up to a few hundred files — would be
|
|
179
|
+
// listed and stat-ed twice per window for the same answer. The TTL sits under
|
|
180
|
+
// MODEL_READ_THROTTLE_MS so two callers inside one window share a walk and the
|
|
181
|
+
// next window always gets a fresh one.
|
|
182
|
+
const subagentDirScans = new Map(); // transcriptPath -> { at, promise }
|
|
183
|
+
const SUBAGENT_DIR_TTL_MS = 2000;
|
|
184
|
+
|
|
185
|
+
function scanSubagentDir(transcriptPath) {
|
|
186
|
+
if (!transcriptPath || typeof transcriptPath !== "string") return Promise.resolve(null);
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
// Expiry is the eviction rule: an entry outlives its window by nothing, so
|
|
189
|
+
// the map holds at most one entry per session read in the last two seconds.
|
|
190
|
+
for (const [k, v] of subagentDirScans) {
|
|
191
|
+
if (now - v.at >= SUBAGENT_DIR_TTL_MS) subagentDirScans.delete(k);
|
|
192
|
+
}
|
|
193
|
+
const memo = subagentDirScans.get(transcriptPath);
|
|
194
|
+
if (memo) return memo.promise;
|
|
195
|
+
const promise = readSubagentsFromDir(transcriptPath).catch(() => null);
|
|
196
|
+
subagentDirScans.set(transcriptPath, { at: now, promise });
|
|
197
|
+
return promise;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function maybeResolveModel(payload) {
|
|
201
|
+
const target = transcriptTarget(payload);
|
|
202
|
+
if (!target) return;
|
|
203
|
+
const { sid, tp } = target;
|
|
204
|
+
// Re-read on every event for this session — the cache was preventing us
|
|
205
|
+
// from picking up subagent models that arrive after the root is known.
|
|
206
|
+
// Throttle so we don't thrash the filesystem.
|
|
207
|
+
modelReads.run(sid, () => Promise.all([readModelFromTranscript(tp), scanSubagentDir(tp)])
|
|
208
|
+
.then(([result, dir]) => {
|
|
209
|
+
const rootModel = result?.rootModel ?? null;
|
|
210
|
+
// Merge legacy (inline isSidechain) + new (subagents/ dir) maps. Dir
|
|
211
|
+
// wins on conflict since current CC only writes to the dir.
|
|
212
|
+
const subagentModels = { ...(result?.subagentModels ?? {}), ...(dir?.models ?? {}) };
|
|
213
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return;
|
|
214
|
+
const prev = modelBySession.get(sid);
|
|
215
|
+
const subsSig = JSON.stringify(subagentModels);
|
|
216
|
+
if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
|
|
217
|
+
modelBySession.set(sid, { rootModel, subsSig });
|
|
218
|
+
pushEvent({
|
|
219
|
+
hook_event_name: "ModelObserved",
|
|
220
|
+
session_id: sid,
|
|
221
|
+
model: rootModel,
|
|
222
|
+
subagentModels,
|
|
223
|
+
}, "internal");
|
|
224
|
+
}));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Usage enrichment ────────────────────────────────────────────────────
|
|
228
|
+
// Same story as the model: token counts (input/output/cache) are missing
|
|
229
|
+
// from every CC hook payload but present on every assistant message in
|
|
230
|
+
// the transcript JSONL as a `"usage":{…}` block. We sum them across the
|
|
231
|
+
// whole transcript and ship a synthetic UsageObserved event so the
|
|
232
|
+
// session's root agent gets accurate cumulative usage (and therefore the
|
|
233
|
+
// cost columns actually have something to multiply by).
|
|
234
|
+
const USAGE_READ_THROTTLE_MS = 2500;
|
|
235
|
+
const usageReads = sessionReadGate(USAGE_READ_THROTTLE_MS);
|
|
236
|
+
|
|
237
|
+
// Every entry carries its own usage object and we sum every occurrence, so
|
|
238
|
+
// the totals are cumulative over the whole transcript — the running state
|
|
239
|
+
// keeps them across passes and each pass only adds the newly appended blocks.
|
|
240
|
+
//
|
|
241
|
+
// ONE FILE, which is the whole file and no more. This reads the path it is
|
|
242
|
+
// given; the session's delegated spend lives in the sibling `subagents/`
|
|
243
|
+
// directory and is added by `sessionUsageTotals` below. Kept separate because
|
|
244
|
+
// the two are read on different cadences by different callers and the tests
|
|
245
|
+
// that pin the cursor arithmetic pin it one file at a time.
|
|
246
|
+
export async function readUsageFromTranscript(path) {
|
|
247
|
+
const state = await scanTranscript(path);
|
|
248
|
+
if (!state) return null;
|
|
249
|
+
const totals = { ...state.usage };
|
|
250
|
+
if (!hasSpend(totals)) return null;
|
|
251
|
+
return totals;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One transcript's totals broken out by the model that produced them, or null
|
|
255
|
+
* when the scan attributed nothing.
|
|
256
|
+
*
|
|
257
|
+
* A SECOND EXPORT rather than one more key on the object above, and the reason
|
|
258
|
+
* is that object's contract: `readUsageFromTranscript` is asserted with
|
|
259
|
+
* `toEqual` on its whole shape, so a key added to it is a key every caller and
|
|
260
|
+
* every fixture has to learn about. This costs no extra read — `scanTranscript`
|
|
261
|
+
* hands back the state it already folded and coalesces concurrent callers onto
|
|
262
|
+
* one pass — so the pair reads the file exactly as often as the single call
|
|
263
|
+
* did. */
|
|
264
|
+
export async function readUsageByModelFromTranscript(path) {
|
|
265
|
+
const state = await scanTranscript(path);
|
|
266
|
+
if (!state) return null;
|
|
267
|
+
const out = {};
|
|
268
|
+
for (const [model, u] of Object.entries(state.usageByModel)) {
|
|
269
|
+
if (!hasSpend(u)) continue;
|
|
270
|
+
out[model] = { ...u };
|
|
271
|
+
}
|
|
272
|
+
return Object.keys(out).length ? out : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The session's whole spend split by model — its own turns plus every
|
|
276
|
+
* subagent's, merged (#686).
|
|
277
|
+
*
|
|
278
|
+
* The counterpart of `sessionUsageTotals` below and read from the same two
|
|
279
|
+
* places, so the split and the total it splits are always a description of the
|
|
280
|
+
* same bytes. A subagent runs on the model its Task was given, which is
|
|
281
|
+
* routinely not its parent's, so a session sum that carried only the root's
|
|
282
|
+
* models would price delegated tokens at whatever the root is on now — the
|
|
283
|
+
* same mistake as the one being fixed, one level in. */
|
|
284
|
+
export async function sessionUsageByModel(transcriptPath) {
|
|
285
|
+
const [own, dir] = await Promise.all([
|
|
286
|
+
readUsageByModelFromTranscript(transcriptPath),
|
|
287
|
+
scanSubagentDir(transcriptPath),
|
|
288
|
+
]);
|
|
289
|
+
if (!own && !dir?.usageByModel) return null;
|
|
290
|
+
const out = mergeUsageByModel({}, own);
|
|
291
|
+
mergeUsageByModel(out, dir?.usageByModel);
|
|
292
|
+
return Object.keys(out).length ? out : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Everything a Claude session has spent: its own turns plus every subagent it
|
|
297
|
+
* ran. Returns null when the session has spent nothing yet.
|
|
298
|
+
*
|
|
299
|
+
* WHAT THIS NUMBER MEANS, and why it is one number (#685). A session is a
|
|
300
|
+
* bill, and delegating work does not move any of it somewhere else — the
|
|
301
|
+
* subagent's tokens are charged to the same account on the same invoice. The
|
|
302
|
+
* two halves live in two places on disk because CC writes them there:
|
|
303
|
+
* `<sessionId>.jsonl` for the root's turns, `<sessionId>/subagents/agent-*.jsonl`
|
|
304
|
+
* for each delegated one, with no overlap between them (verified against real
|
|
305
|
+
* transcripts: a session holding 397 subagent files had zero `isSidechain`
|
|
306
|
+
* lines in its main JSONL). Summing the two therefore counts every token
|
|
307
|
+
* exactly once, and the reducer ASSIGNS the result rather than adding it, so a
|
|
308
|
+
* pass that lands twice restates the same total instead of doubling it.
|
|
309
|
+
*
|
|
310
|
+
* The one place the two files did overlap is the `toolUseResult` block a
|
|
311
|
+
* finished Task leaves on the parent's line, which restates the subagent's
|
|
312
|
+
* last turn; `billedUsageText` cuts that out of the scan so this sum stays a
|
|
313
|
+
* sum of distinct tokens.
|
|
314
|
+
*/
|
|
315
|
+
export async function sessionUsageTotals(transcriptPath) {
|
|
316
|
+
const [own, dir] = await Promise.all([
|
|
317
|
+
readUsageFromTranscript(transcriptPath),
|
|
318
|
+
scanSubagentDir(transcriptPath),
|
|
319
|
+
]);
|
|
320
|
+
const delegated = dir?.usage ?? null;
|
|
321
|
+
if (!own && !delegated) return null;
|
|
322
|
+
const totals = own ? { ...own } : newUsageTotals();
|
|
323
|
+
if (delegated) for (const k of Object.keys(totals)) totals[k] += delegated[k] ?? 0;
|
|
324
|
+
return totals;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function maybeResolveUsage(payload) {
|
|
328
|
+
const target = transcriptTarget(payload);
|
|
329
|
+
if (!target) return;
|
|
330
|
+
const { sid, tp } = target;
|
|
331
|
+
usageReads.run(sid, () => Promise.all([sessionUsageTotals(tp), sessionUsageByModel(tp)])
|
|
332
|
+
.then(([usage, usageByModel]) => {
|
|
333
|
+
if (!usage) return;
|
|
334
|
+
// `usageByModel` rides on the same event because it is the same
|
|
335
|
+
// measurement, read from the same two places by the same walk — the main
|
|
336
|
+
// transcript and the `subagents/` directory — so the split and the total
|
|
337
|
+
// it splits cannot describe two different moments of the session. Sent
|
|
338
|
+
// even when null: an absent split has to CLEAR a stale one on the client,
|
|
339
|
+
// for the reason the flat totals are assigned rather than added.
|
|
340
|
+
pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage, usageByModel }, "internal");
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ─── Session-name enrichment ─────────────────────────────────────────────
|
|
345
|
+
// WHY THIS IS NOT A TAIL READ. The obvious shape for "get the newest naming
|
|
346
|
+
// record out of a 46 MB file" is to read the last N KB and parse backwards, and
|
|
347
|
+
// it is the wrong shape here for two reasons, one of them measured.
|
|
348
|
+
//
|
|
349
|
+
// The measured one: the density is not uniform, so no N is safe. Back-scanning
|
|
350
|
+
// from an arbitrary point in the 46.4 MB transcript to the nearest `ai-title`
|
|
351
|
+
// costs p50 38 KB but p95 227 KB, p99 511 KB and 723 KB worst case, because a
|
|
352
|
+
// single line in that file reaches 710 KB — one big tool result evicts every
|
|
353
|
+
// naming record from any window you picked. A 256 KB tail covers 95.9% of
|
|
354
|
+
// positions, 512 KB covers 99.1%, and the last percent still needs a megabyte.
|
|
355
|
+
//
|
|
356
|
+
// The structural one: it would be a second reader of bytes this process is
|
|
357
|
+
// already reading. `scanTranscript` keeps a per-path CURSOR and folds each
|
|
358
|
+
// appended line exactly once, for the model, the usage totals and the context
|
|
359
|
+
// counts alike. Folding two more fields into that pass (see
|
|
360
|
+
// `foldSessionNamingLine`) costs no read at all, sees every record rather than
|
|
361
|
+
// a window of them, and cannot be defeated by a 710 KB line.
|
|
362
|
+
//
|
|
363
|
+
// That also settles the trigger. With a tail read the trigger IS the cost
|
|
364
|
+
// control, so you have to pick a boundary and `Stop` is the honest one. With a
|
|
365
|
+
// cursor the bytes are read once whoever asks, so the trigger only decides how
|
|
366
|
+
// LATE the name appears — and gating on `Stop` would hold a name the scan
|
|
367
|
+
// already has until the turn ends, for a saving of zero. So this runs off any
|
|
368
|
+
// hook event like its three neighbours, throttled per session, and the throttle
|
|
369
|
+
// matches MODEL_READ_THROTTLE_MS so the two passes coincide and share one
|
|
370
|
+
// in-flight scan.
|
|
371
|
+
//
|
|
372
|
+
// The emit is what is actually kept rare, and it is gated on a CHANGE: with 685
|
|
373
|
+
// records carrying 2 distinct values, a per-pass emit would be ~683 events
|
|
374
|
+
// saying nothing. Sessions that never get named emit nothing at all.
|
|
375
|
+
const nameBySession = new Map(); // sid -> `${agentName}\0${aiTitle}`
|
|
376
|
+
const nameReads = sessionReadGate(MODEL_READ_THROTTLE_MS);
|
|
377
|
+
|
|
378
|
+
/** The naming the cursor has folded so far, or null when the scan has nothing.
|
|
379
|
+
*
|
|
380
|
+
* NOT exported, and the comment here used to say it was — "beside
|
|
381
|
+
* readContextFromTranscript … the rule is worth pinning directly rather than
|
|
382
|
+
* through a live server" — which stated a test contract no test had (#798).
|
|
383
|
+
* What is worth pinning is the parsing, and that is `foldSessionNamingLine`,
|
|
384
|
+
* which IS exported and which session-name.test.ts drives line at a time the
|
|
385
|
+
* way `foldTranscriptLine` does. This wrapper is a scan and two null checks.
|
|
386
|
+
*
|
|
387
|
+
* The recap comes back beside the naming because it comes off the same scan:
|
|
388
|
+
* `naming` is null when the transcript has no naming record, `recap` when no
|
|
389
|
+
* recap is standing. See session-recap.mjs, and `noteRecap` below. */
|
|
390
|
+
async function readSessionNamingFromTranscript(path) {
|
|
391
|
+
const state = await scanTranscript(path);
|
|
392
|
+
if (!state) return null;
|
|
393
|
+
const naming = (state.agentName || state.aiTitle)
|
|
394
|
+
? { agentName: state.agentName, aiTitle: state.aiTitle }
|
|
395
|
+
: null;
|
|
396
|
+
return { naming, recap: state.recap ?? null };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function maybeResolveSessionName(payload) {
|
|
400
|
+
const target = transcriptTarget(payload);
|
|
401
|
+
if (!target) return;
|
|
402
|
+
const { sid, tp } = target;
|
|
403
|
+
nameReads.run(sid, () => readSessionNamingFromTranscript(tp)
|
|
404
|
+
.then(read => {
|
|
405
|
+
if (!read) return;
|
|
406
|
+
noteRecap(sid, read.recap);
|
|
407
|
+
const naming = read.naming;
|
|
408
|
+
if (!naming) return;
|
|
409
|
+
const sig = `${naming.agentName ?? ""}\u0000${naming.aiTitle ?? ""}`;
|
|
410
|
+
if (nameBySession.get(sid) === sig) return;
|
|
411
|
+
nameBySession.set(sid, sig);
|
|
412
|
+
pushEvent({
|
|
413
|
+
hook_event_name: "SessionNamed",
|
|
414
|
+
session_id: sid,
|
|
415
|
+
sessionName: naming.agentName ?? null,
|
|
416
|
+
sessionTitle: naming.aiTitle ?? null,
|
|
417
|
+
}, "internal");
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ─── Session recap ───────────────────────────────────────────────────────
|
|
422
|
+
// Claude Code's "※ recap:" line (see session-recap.mjs). It comes off the
|
|
423
|
+
// transcript cursor like the naming above, and its emit is gated the same way:
|
|
424
|
+
// on a CHANGE, keyed by the recap's own timestamp, so a pass that re-reads a
|
|
425
|
+
// recap already sent says nothing. A session that never had one emits nothing
|
|
426
|
+
// at all — the first word about a session's recap is always a recap.
|
|
427
|
+
//
|
|
428
|
+
// TWO ROADS IN, BECAUSE NO HOOK FIRES WHEN IT LANDS. The cursor runs off hook
|
|
429
|
+
// events, and a recap is written three minutes into a silence that has none.
|
|
430
|
+
// So the output watch, which already stats these files between events, hands
|
|
431
|
+
// every tail it reads to `onRecapTail`, and a tail carrying a recap asks the
|
|
432
|
+
// cursor to fold the file. The cursor stays the one place the rule lives: a
|
|
433
|
+
// recap a later turn retired is retired whichever road found it.
|
|
434
|
+
const recapBySession = new Map(); // sid -> `${at}` last sent, "" once retired
|
|
435
|
+
|
|
436
|
+
function noteRecap(sid, recap) {
|
|
437
|
+
const sig = recap ? String(recap.at) : "";
|
|
438
|
+
const prev = recapBySession.get(sid);
|
|
439
|
+
if (prev === sig) return;
|
|
440
|
+
if (prev === undefined && !recap) return;
|
|
441
|
+
recapBySession.set(sid, sig);
|
|
442
|
+
pushEvent({
|
|
443
|
+
hook_event_name: "SessionRecapped",
|
|
444
|
+
session_id: sid,
|
|
445
|
+
recap: recap ? { text: recap.text, at: recap.at } : null,
|
|
446
|
+
}, "internal");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The output watch's tap. Only a tail carrying the recap mark costs a scan,
|
|
450
|
+
* and the scan is the cursor's own: it folds the bytes it has not seen yet. */
|
|
451
|
+
function onRecapTail(sid, text, path) {
|
|
452
|
+
if (!path || !text.includes(RECAP_MARK)) return;
|
|
453
|
+
scanTranscript(path)
|
|
454
|
+
.then(state => { if (state) noteRecap(sid, state.recap ?? null); })
|
|
455
|
+
.catch(() => {});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── Context enrichment ──────────────────────────────────────────────────
|
|
459
|
+
// Approximation of `/context` since CC doesn't expose its breakdown via
|
|
460
|
+
// hooks. We scan the transcript JSONL for message counts (user / assistant
|
|
461
|
+
// / tool_use / tool_result / system-reminders) and walk up from cwd for
|
|
462
|
+
// any CLAUDE.md files in scope. Cumulative token totals come from
|
|
463
|
+
// UsageObserved; this scan produces the structural counts ("what does the
|
|
464
|
+
// context contain") plus the current window size — currentContextTokens, the
|
|
465
|
+
// last usage block after the most recent /clear or /compact, which is what
|
|
466
|
+
// the context donut and the modal's percentage are drawn from.
|
|
467
|
+
const CONTEXT_READ_THROTTLE_MS = 4000;
|
|
468
|
+
const contextReads = sessionReadGate(CONTEXT_READ_THROTTLE_MS);
|
|
469
|
+
|
|
470
|
+
// The counts reset at every `/clear` or `/compact` marker (see foldContextLine
|
|
471
|
+
// in transcript-scan.mjs): CC resets its in-memory window there while the
|
|
472
|
+
// JSONL keeps growing, and reading the pre-reset blocks made the donut report
|
|
473
|
+
// ~100% on an empty context.
|
|
474
|
+
export async function readContextFromTranscript(path) {
|
|
475
|
+
const state = await scanTranscript(path);
|
|
476
|
+
// Nothing folded yet — the file is empty, unreadable, or has no complete
|
|
477
|
+
// line. Callers treat that as "no breakdown", same as before.
|
|
478
|
+
if (!state || state.offset === 0) return null;
|
|
479
|
+
return { ...state.ctx };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function maybeResolveContext(payload) {
|
|
483
|
+
const target = transcriptTarget(payload);
|
|
484
|
+
if (!target) return;
|
|
485
|
+
const { sid, tp } = target;
|
|
486
|
+
const cwd = payload.cwd;
|
|
487
|
+
contextReads.run(sid, () => Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
|
|
488
|
+
.then(([breakdown, memoryFiles]) => {
|
|
489
|
+
if (!breakdown && (!memoryFiles || memoryFiles.length === 0)) return;
|
|
490
|
+
pushEvent({
|
|
491
|
+
hook_event_name: "ContextObserved",
|
|
492
|
+
session_id: sid,
|
|
493
|
+
context: {
|
|
494
|
+
...(breakdown ?? {}),
|
|
495
|
+
memoryFiles: memoryFiles ?? [],
|
|
496
|
+
},
|
|
497
|
+
}, "internal");
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Throttle state for the Codex half of the same question. A gate of its own
|
|
502
|
+
// rather than sharing maybeResolveContext's, because the two run on different
|
|
503
|
+
// triggers — a hook payload there, a batch of appended rollout lines here — and
|
|
504
|
+
// one session cannot be both.
|
|
505
|
+
const codexMemoryReads = sessionReadGate(CONTEXT_READ_THROTTLE_MS);
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Emit the memory files a Codex session has in scope, throttled per session.
|
|
509
|
+
*
|
|
510
|
+
* WHY THIS IS NOT maybeResolveContext (#399). That function early-returns
|
|
511
|
+
* without `payload.transcript_path`, a field only Claude Code sends, and it
|
|
512
|
+
* would scan for CLAUDE.md if it got that far. It is also unreachable from here
|
|
513
|
+
* on a second count: pushEvent gates all of its enrichment on `source ===
|
|
514
|
+
* "hook"`, and the Codex rollout watcher emits with source "codex" because it is
|
|
515
|
+
* not a hook stream at all. So this is called from the watcher's own scan loop,
|
|
516
|
+
* next to the lazy root, rather than off the back of an event.
|
|
517
|
+
*
|
|
518
|
+
* There is no structural breakdown alongside the file list, and that is the
|
|
519
|
+
* honest answer rather than a gap: the counts the Claude side reports —
|
|
520
|
+
* user/assistant messages, tool uses, tool results, system-reminders — come from
|
|
521
|
+
* a regex scan of a transcript this deck has read from byte zero, and the
|
|
522
|
+
* watcher deliberately SKIPS a pre-existing session's history at startup
|
|
523
|
+
* (`state.offset = st.size`). Counting from the moment the deck attached would
|
|
524
|
+
* produce five confident numbers that are all short by however much of the
|
|
525
|
+
* session happened first, on a panel whose whole purpose is to say what is in
|
|
526
|
+
* the window. ContextModal says so in the slot those counts would have used;
|
|
527
|
+
* see codex-approval.ts for the same move on a different unanswerable question.
|
|
528
|
+
*
|
|
529
|
+
* WHY `persist` IS A PARAMETER (#447). Every other event the rollout watcher
|
|
530
|
+
* produces goes out through emitCodexEvent, which carries the per-batch verdict
|
|
531
|
+
* of writesCodexLog: several decks tailing one rollout all DRAW it, and exactly
|
|
532
|
+
* one of them appends it to the events.jsonl they share. This function pushed
|
|
533
|
+
* with no opts at all, so pushEvent fell back to writesLogFor — and that answers
|
|
534
|
+
* from `foreignSessions`, a set only ever filled by noteLogWriter off an
|
|
535
|
+
* incoming hook POST marked `?persist=0`. A Codex rollout never touches an HTTP
|
|
536
|
+
* handler and Codex hooks are not installed any more (installer.mjs keeps the
|
|
537
|
+
* provider for uninstall only), so a Codex session id can never appear in that
|
|
538
|
+
* set and every deck answered "yes, mine" for this one event. The caller already
|
|
539
|
+
* holds the verdict for the whole batch, so it is threaded in rather than
|
|
540
|
+
* re-derived: recomputing it here would cost a second directory listing and
|
|
541
|
+
* could disagree with the roots and tool calls the same batch just emitted.
|
|
542
|
+
*
|
|
543
|
+
* Omitting the argument keeps the old behaviour — write. That is the same
|
|
544
|
+
* fail-safe writesCodexLog itself takes when a deck cannot find its own
|
|
545
|
+
* discovery record: a line written twice is recoverable, a deck that quietly
|
|
546
|
+
* records nothing is not.
|
|
547
|
+
*/
|
|
548
|
+
function maybeResolveCodexMemory(sid, cwd, persist) {
|
|
549
|
+
if (!sid || !cwd) return;
|
|
550
|
+
codexMemoryReads.run(sid, () => scanAgentsMdFiles(cwd)
|
|
551
|
+
.then(memoryFiles => {
|
|
552
|
+
// Nothing found is not a fact worth an event: the reducer merges a
|
|
553
|
+
// ContextObserved into whatever the session already had, and an empty list
|
|
554
|
+
// would only ever overwrite a real one with nothing. A repo that grows its
|
|
555
|
+
// first AGENTS.md mid-session is picked up by the next throttled pass.
|
|
556
|
+
if (!memoryFiles.length) return;
|
|
557
|
+
// `persist` false means another deck tailing this same rollout was elected
|
|
558
|
+
// to record it. The event is still buffered and broadcast from here, so
|
|
559
|
+
// every deck's context modal lists the AGENTS.md files — it is only the
|
|
560
|
+
// second copy on disk that is dropped, exactly as emitCodexEvent does it.
|
|
561
|
+
pushEvent({
|
|
562
|
+
hook_event_name: "ContextObserved",
|
|
563
|
+
session_id: sid,
|
|
564
|
+
provider: "codex",
|
|
565
|
+
context: { memoryFiles },
|
|
566
|
+
}, "internal", { persist });
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Everything the enrichment passes above keep for one session, dropped. Its
|
|
572
|
+
* one caller is forgetSession, which says when a session is forgotten and why.
|
|
573
|
+
*/
|
|
574
|
+
function forgetEnrichment(sid) {
|
|
575
|
+
modelBySession.delete(sid);
|
|
576
|
+
// The two the session-naming work added (#520/#522) and did not list here.
|
|
577
|
+
// Both are keyed by session id and nothing else ever removed an entry, which
|
|
578
|
+
// is the exact leak the comment above forgetSession (session-tracking.mjs)
|
|
579
|
+
// says this mechanism exists to end — every sibling cache is capped at
|
|
580
|
+
// MAX_TRACKED_SESSIONS and these two were not. The functional half is worse
|
|
581
|
+
// than the leak: nameBySession gates the SessionNamed emit on "has this
|
|
582
|
+
// changed", so a live session evicted past the cap and then heard from again
|
|
583
|
+
// re-emits its model (modelBySession was cleared) and never re-emits its
|
|
584
|
+
// name. A tab that connects after the event ring has rolled past the
|
|
585
|
+
// original SessionNamed shows that session unnamed for the rest of its life.
|
|
586
|
+
nameBySession.delete(sid);
|
|
587
|
+
// The recap's gate, for the same reason — see noteRecap.
|
|
588
|
+
recapBySession.delete(sid);
|
|
589
|
+
nameReads.forget(sid);
|
|
590
|
+
modelReads.forget(sid);
|
|
591
|
+
usageReads.forget(sid);
|
|
592
|
+
contextReads.forget(sid);
|
|
593
|
+
codexMemoryReads.forget(sid);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Every session's "has this changed" gates, and the read stamps that would hold
|
|
598
|
+
* the next re-read back, dropped at once. Its one caller is handleClear, which
|
|
599
|
+
* says why a Clear has to.
|
|
600
|
+
*/
|
|
601
|
+
function clearEnrichmentGates() {
|
|
602
|
+
nameBySession.clear();
|
|
603
|
+
recapBySession.clear();
|
|
604
|
+
modelBySession.clear();
|
|
605
|
+
// The read stamps go with them. Clearing only the signatures would leave
|
|
606
|
+
// the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
|
|
607
|
+
// would not be re-read at all and the name would stay missing until the
|
|
608
|
+
// throttle expired — a clear followed by a keystroke is exactly when a
|
|
609
|
+
// user is watching.
|
|
610
|
+
nameReads.forgetAll();
|
|
611
|
+
modelReads.forgetAll();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// What event-pipeline.mjs, session-tracking.mjs, clear-route.mjs and the
|
|
615
|
+
// rollout watcher call besides the readers exported above. Listed rather than
|
|
616
|
+
// marked at each declaration, so the declarations read as they did where they
|
|
617
|
+
// came from.
|
|
618
|
+
export {
|
|
619
|
+
clearEnrichmentGates, forgetEnrichment, knownModelId, maybeResolveCodexMemory,
|
|
620
|
+
maybeResolveContext, maybeResolveModel, maybeResolveSessionName, maybeResolveUsage,
|
|
621
|
+
onRecapTail,
|
|
622
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The gate every enrichment pass puts in front of its read: one read per
|
|
2
|
+
// session at a time, and none sooner than a window after the last one began.
|
|
3
|
+
//
|
|
4
|
+
// It was written out six times — the model, usage, session-name and context
|
|
5
|
+
// passes in session-enrichment.mjs, the Codex memory scan beside them, and the
|
|
6
|
+
// Codex usage read in codex-enrichment.mjs — each as its own pair of a
|
|
7
|
+
// `last…ReadAt` map and a `pending…Reads` set with the same eight lines in
|
|
8
|
+
// front of its read. Six copies of one rule are six places for it to drift,
|
|
9
|
+
// and the only thing that differed between them was the window. So the pair
|
|
10
|
+
// lives here, once, behind the three operations its callers used on it.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A per-session read gate with a window of `ms`.
|
|
14
|
+
*
|
|
15
|
+
* `run(sid, read)` calls `read()` unless a read for `sid` is still in flight or
|
|
16
|
+
* the last one began less than `ms` ago. The stamp is taken as the read starts,
|
|
17
|
+
* so a read that fails or finds nothing still holds the next one back for the
|
|
18
|
+
* window — that is the throttle. `read` is called before `run` returns, as
|
|
19
|
+
* every copy called it, and returns a promise. Whatever that rejects with is
|
|
20
|
+
* swallowed, because nothing awaits a pass fired off the hot path, and the
|
|
21
|
+
* session is let through again once it settles.
|
|
22
|
+
*
|
|
23
|
+
* `forget(sid)` drops one session's stamp and `forgetAll()` drops every stamp,
|
|
24
|
+
* so the next event for it reads at once. Neither touches a read in flight: it
|
|
25
|
+
* settles and lets its session through exactly as it would have.
|
|
26
|
+
*/
|
|
27
|
+
export function sessionReadGate(ms) {
|
|
28
|
+
const lastReadAt = new Map(); // sid -> ms timestamp the last read began
|
|
29
|
+
const pending = new Set(); // sid currently being read
|
|
30
|
+
return {
|
|
31
|
+
run(sid, read) {
|
|
32
|
+
if (pending.has(sid)) return;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const last = lastReadAt.get(sid) ?? 0;
|
|
35
|
+
if (now - last < ms) return;
|
|
36
|
+
lastReadAt.set(sid, now);
|
|
37
|
+
pending.add(sid);
|
|
38
|
+
read()
|
|
39
|
+
.catch(() => {})
|
|
40
|
+
.finally(() => pending.delete(sid));
|
|
41
|
+
},
|
|
42
|
+
forget(sid) {
|
|
43
|
+
lastReadAt.delete(sid);
|
|
44
|
+
},
|
|
45
|
+
forgetAll() {
|
|
46
|
+
lastReadAt.clear();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|