ccdeck 1.43.0 → 1.44.1
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 +208 -12
- package/bin/agent-dag.js +481 -0
- package/bin/deck.js +968 -0
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +407 -0
- package/hook/notify.mjs +104 -0
- package/package.json +42 -9
- package/src/server/args.mjs +58 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +383 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +353 -0
- package/src/server/cswap-admin.mjs +902 -0
- package/src/server/cswap-auto.mjs +441 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +850 -0
- package/src/server/index.mjs +3479 -0
- package/src/server/installer.mjs +674 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/sound-hook.mjs +518 -0
- package/src/server/supervisor.mjs +235 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -0,0 +1,3479 @@
|
|
|
1
|
+
// agent-dag server: HTTP ingest + SSE broadcast + static file serving.
|
|
2
|
+
// Single-file pure Node HTTP server, zero deps.
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
|
|
5
|
+
import { createReadStream, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { extname, join, resolve, dirname as pdirname } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
13
|
+
import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
|
|
14
|
+
import { PRODUCT } from "./brand.mjs";
|
|
15
|
+
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
16
|
+
import { appendLogLine, codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
|
|
17
|
+
import { readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
|
|
18
|
+
|
|
19
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PKG_ROOT = resolve(__dirname, "..", "..");
|
|
21
|
+
const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
|
|
22
|
+
|
|
23
|
+
// Read at import — i.e. at boot, before an upgrade can overwrite these files.
|
|
24
|
+
// Reading it later would report whatever npm has since installed and hide the
|
|
25
|
+
// exact drift /api/version exists to expose. See src/server/self-update.mjs.
|
|
26
|
+
const RUNNING_VERSION = (() => {
|
|
27
|
+
try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"))?.version ?? null; }
|
|
28
|
+
catch { return null; }
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
const MIME = {
|
|
32
|
+
".html": "text/html; charset=utf-8",
|
|
33
|
+
".js": "application/javascript; charset=utf-8",
|
|
34
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
35
|
+
".css": "text/css; charset=utf-8",
|
|
36
|
+
".json": "application/json; charset=utf-8",
|
|
37
|
+
".svg": "image/svg+xml",
|
|
38
|
+
".png": "image/png",
|
|
39
|
+
".jpg": "image/jpeg",
|
|
40
|
+
".woff2": "font/woff2",
|
|
41
|
+
".map": "application/json",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
|
|
45
|
+
const events = []; // ring buffer
|
|
46
|
+
let nextSeq = 1;
|
|
47
|
+
// Identity of *this* process's seq numbering. nextSeq restarts at 1 on every
|
|
48
|
+
// boot and is re-derived by replaying events.jsonl, so it is monotonic only
|
|
49
|
+
// within one process: rotation (50MB) or /api/clear shortens that log and the
|
|
50
|
+
// next boot starts numbering well below what an already-open tab saw. Stamped
|
|
51
|
+
// on every envelope so a client can tell "counter restarted" apart from "old
|
|
52
|
+
// duplicate" instead of silently dropping the live stream.
|
|
53
|
+
const SEQ_EPOCH = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
54
|
+
const sseClients = new Set(); // res handles
|
|
55
|
+
|
|
56
|
+
let persistPath = null; // absolute path to events.jsonl, or null
|
|
57
|
+
|
|
58
|
+
// ─── Which deck records which session ─────────────────────────────────────
|
|
59
|
+
// The hook posts an event to every deck whose workspace matches, and by
|
|
60
|
+
// default all of them append to one events.jsonl — so each event landed in
|
|
61
|
+
// that file once per running deck, and every later replay of it ingested each
|
|
62
|
+
// tool call that many times. The hook now elects one writer per log file and
|
|
63
|
+
// marks the request to the rest `?persist=0`. That flag covers the hook event
|
|
64
|
+
// itself; this map carries it to the ModelObserved/UsageObserved/
|
|
65
|
+
// ContextObserved events a deck derives from the same session's transcript,
|
|
66
|
+
// which every deck reads for itself and would otherwise duplicate exactly the
|
|
67
|
+
// same way. A session is recorded unless we have been told someone else owns
|
|
68
|
+
// it, so a lone deck — and a deck the hook is too old to flag — still writes
|
|
69
|
+
// everything.
|
|
70
|
+
//
|
|
71
|
+
// THIS COVERS THE CLAUDE SIDE ONLY, and that is worth stating because reading it
|
|
72
|
+
// as the general answer is what produced #447. The only thing that ever fills
|
|
73
|
+
// the set is noteLogWriter, called from handleEventIngest — a hook POST. Codex
|
|
74
|
+
// events are read off the rollout files inside this process and never reach an
|
|
75
|
+
// HTTP handler, and Codex hooks are not installed any more, so a Codex session
|
|
76
|
+
// id cannot get in here and writesLogFor answers "mine" for every one of them on
|
|
77
|
+
// every deck. The Codex half is decided by writesCodexLog in the watcher's scan
|
|
78
|
+
// loop instead, and every event derived from a rollout — including the memory
|
|
79
|
+
// scan's ContextObserved — has to carry that verdict explicitly.
|
|
80
|
+
const foreignSessions = new Set(); // session ids another deck is logging
|
|
81
|
+
const MAX_FOREIGN_SESSIONS = 512; // bound: insertion order = oldest first
|
|
82
|
+
|
|
83
|
+
function noteLogWriter(payload, mine) {
|
|
84
|
+
const sid = payload && typeof payload === "object" ? payload.session_id : null;
|
|
85
|
+
if (typeof sid !== "string" || sid === "") return;
|
|
86
|
+
// Delete either way: on re-add it moves the id back to the young end, so the
|
|
87
|
+
// eviction below drops sessions that stopped being posted, not busy ones.
|
|
88
|
+
foreignSessions.delete(sid);
|
|
89
|
+
if (mine) return;
|
|
90
|
+
foreignSessions.add(sid);
|
|
91
|
+
if (foreignSessions.size > MAX_FOREIGN_SESSIONS) {
|
|
92
|
+
foreignSessions.delete(foreignSessions.values().next().value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Is this deck the one that writes this payload's session to the log? Every
|
|
98
|
+
* event goes through here on its way to disk, including the ones this deck
|
|
99
|
+
* derives from a transcript on its own — that is the point of remembering the
|
|
100
|
+
* session rather than only honouring the flag on the event that carried it.
|
|
101
|
+
*/
|
|
102
|
+
export function writesLogFor(payload) {
|
|
103
|
+
const sid = payload && typeof payload === "object" ? payload.session_id : null;
|
|
104
|
+
return typeof sid !== "string" || sid === "" || !foreignSessions.has(sid);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Persistence rotation ─────────────────────────────────────────────────
|
|
108
|
+
// 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
|
|
109
|
+
// across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
|
|
110
|
+
// the previous file to .1 and starting fresh. Last-event-id replay still
|
|
111
|
+
// covers the in-memory ring buffer of MAX_BUFFER events.
|
|
112
|
+
const ROTATE_AT_BYTES = 50 * 1024 * 1024;
|
|
113
|
+
let lastRotateCheckAt = 0;
|
|
114
|
+
let rotateInProgress = false;
|
|
115
|
+
async function maybeRotatePersistFile() {
|
|
116
|
+
if (!persistPath) return;
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
// Throttle disk-stat checks to once per 30s.
|
|
119
|
+
if (now - lastRotateCheckAt < 30_000) return;
|
|
120
|
+
lastRotateCheckAt = now;
|
|
121
|
+
if (rotateInProgress) return;
|
|
122
|
+
rotateInProgress = true;
|
|
123
|
+
try {
|
|
124
|
+
const s = await stat(persistPath).catch(() => null);
|
|
125
|
+
if (!s || s.size < ROTATE_AT_BYTES) return;
|
|
126
|
+
// Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
|
|
127
|
+
const oldPath = persistPath + ".1";
|
|
128
|
+
try { await unlink(oldPath); } catch {}
|
|
129
|
+
const { rename } = await import("node:fs/promises");
|
|
130
|
+
await rename(persistPath, oldPath);
|
|
131
|
+
console.log(`${PRODUCT}: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error(`${PRODUCT}: persist rotation failed:`, err && err.message ? err.message : err);
|
|
134
|
+
} finally {
|
|
135
|
+
rotateInProgress = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Incremental transcript scanning ─────────────────────────────────────
|
|
140
|
+
// Model, usage and context enrichment all derive from the same append-only
|
|
141
|
+
// transcript JSONL, and each one used to re-read and re-parse the whole file
|
|
142
|
+
// on every throttled pass. A session's transcript grows to tens of MB, so
|
|
143
|
+
// that cost O(n) per pass, O(n²) over the session, and — because the parse
|
|
144
|
+
// loop is one synchronous block — it stalled SSE broadcasts and /api/event
|
|
145
|
+
// ingest for as long as it ran.
|
|
146
|
+
//
|
|
147
|
+
// Instead we keep one cursor per file plus the running totals derived so
|
|
148
|
+
// far, read only the bytes appended since the last pass, and fold them into
|
|
149
|
+
// that state. The three scanners share it, so the first one to run in a
|
|
150
|
+
// cycle pays for the read and the other two reuse the result. This mirrors
|
|
151
|
+
// the offset tailing the Codex rollout watcher already does further down.
|
|
152
|
+
const transcriptScans = new Map(); // path -> scan state
|
|
153
|
+
const transcriptScanInFlight = new Map(); // path -> in-progress scan promise
|
|
154
|
+
const MAX_TRANSCRIPT_SCANS = 256; // bound the per-path state
|
|
155
|
+
|
|
156
|
+
// The transcript's `message.model`, and the only filter standing between it and
|
|
157
|
+
// every model the deck shows. Bedrock and Mantle put a provider namespace in
|
|
158
|
+
// front of the id — `us.anthropic.claude-opus-5`, `anthropic.claude-opus-5` —
|
|
159
|
+
// so a bare `^claude` dropped every line a Bedrock session writes and the deck
|
|
160
|
+
// showed those users no model, no context window and no cost at all (#475).
|
|
161
|
+
//
|
|
162
|
+
// The prefix list is `VENDOR_PREFIX_RE` in src/web/model-id.ts, written out a
|
|
163
|
+
// second time here rather than imported: this file is plain .mjs that node runs
|
|
164
|
+
// straight off disk with no build step, so it cannot reach a `.ts` module.
|
|
165
|
+
// bedrock-model-ids.test.ts sweeps both against one list of ids so the copies
|
|
166
|
+
// cannot drift apart.
|
|
167
|
+
const MODEL_ID_RE = /^(?:(?:us-gov|global|apac|us|eu|jp|au)\.anthropic\.|anthropic\.)?claude[-_]/i;
|
|
168
|
+
const USAGE_BLOCK_RE = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
169
|
+
// CC `/clear` and `/compact` write a marker into the transcript and reset the
|
|
170
|
+
// context window to ~0 while the JSONL keeps growing — everything before the
|
|
171
|
+
// last marker is stale.
|
|
172
|
+
const CONTEXT_RESET_RE = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
|
|
173
|
+
const TYPE_USER_RE = /"type"\s*:\s*"user"/g;
|
|
174
|
+
const TYPE_ASSISTANT_RE = /"type"\s*:\s*"assistant"/g;
|
|
175
|
+
const TYPE_TOOL_USE_RE = /"type"\s*:\s*"tool_use"/g;
|
|
176
|
+
const TYPE_TOOL_RESULT_RE = /"type"\s*:\s*"tool_result"/g;
|
|
177
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>/g;
|
|
178
|
+
const USAGE_FIELD_RE = {
|
|
179
|
+
input_tokens: /"input_tokens"\s*:\s*(\d+)/,
|
|
180
|
+
output_tokens: /"output_tokens"\s*:\s*(\d+)/,
|
|
181
|
+
cache_read_input_tokens: /"cache_read_input_tokens"\s*:\s*(\d+)/,
|
|
182
|
+
cache_creation_input_tokens: /"cache_creation_input_tokens"\s*:\s*(\d+)/,
|
|
183
|
+
ephemeral_1h_input_tokens: /"ephemeral_1h_input_tokens"\s*:\s*(\d+)/,
|
|
184
|
+
ephemeral_5m_input_tokens: /"ephemeral_5m_input_tokens"\s*:\s*(\d+)/,
|
|
185
|
+
};
|
|
186
|
+
// The flat `cache_creation_input_tokens` says how many tokens were written to
|
|
187
|
+
// cache but not at which TTL, and Anthropic bills a 1-hour write at 2x input
|
|
188
|
+
// against the 5-minute 1.25x — CC writes 1-hour caches for most of its prefix,
|
|
189
|
+
// so pricing the whole lot at the cheaper rate under-reports the bill. The
|
|
190
|
+
// split lives in a `cache_creation` sub-object that USAGE_BLOCK_RE cannot
|
|
191
|
+
// reach: its `[^}]+` stops at the first `}`, which in a real transcript closes
|
|
192
|
+
// `server_tool_use`, several fields earlier. Match the sub-object on the raw
|
|
193
|
+
// line instead of on the extracted blob.
|
|
194
|
+
const CACHE_CREATION_BLOCK_RE = /"cache_creation"\s*:\s*\{([^}]*)\}/g;
|
|
195
|
+
|
|
196
|
+
function grabUsageField(blob, key) {
|
|
197
|
+
const m = blob.match(USAGE_FIELD_RE[key]);
|
|
198
|
+
return m ? Number(m[1]) : 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function newContextBreakdown() {
|
|
202
|
+
return {
|
|
203
|
+
msgsUser: 0,
|
|
204
|
+
msgsAssistant: 0,
|
|
205
|
+
toolUses: 0,
|
|
206
|
+
toolResults: 0,
|
|
207
|
+
systemReminders: 0,
|
|
208
|
+
currentContextTokens: 0,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function newTranscriptState() {
|
|
213
|
+
return {
|
|
214
|
+
offset: 0, // bytes already folded in
|
|
215
|
+
rootModel: null,
|
|
216
|
+
lastModel: null, // last claude-* model on any line, sidechain included
|
|
217
|
+
subagentModels: {},
|
|
218
|
+
aiTitle: null, // newest "ai-title" entry, the session's sentence title
|
|
219
|
+
agentName: null, // newest "agent-name" entry, the session's short name
|
|
220
|
+
usage: {
|
|
221
|
+
input_tokens: 0, output_tokens: 0,
|
|
222
|
+
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
|
|
223
|
+
ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0,
|
|
224
|
+
},
|
|
225
|
+
ctx: newContextBreakdown(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function readByteRange(path, from, to) {
|
|
230
|
+
const fh = await open(path, "r");
|
|
231
|
+
try {
|
|
232
|
+
const len = to - from;
|
|
233
|
+
if (len <= 0) return "";
|
|
234
|
+
const buf = Buffer.alloc(len);
|
|
235
|
+
await fh.read(buf, 0, len, from);
|
|
236
|
+
return buf.toString("utf8");
|
|
237
|
+
} finally {
|
|
238
|
+
await fh.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Session naming ──────────────────────────────────────────────────────
|
|
243
|
+
// CC writes two whole-line records that name the session, and nothing else in
|
|
244
|
+
// the transcript carries either fact:
|
|
245
|
+
//
|
|
246
|
+
// {"type":"ai-title","aiTitle":"Inspect repository to understand current state","sessionId":"…"}
|
|
247
|
+
// {"type":"agent-name","agentName":"account-management-oauth-flow","sessionId":"…"}
|
|
248
|
+
//
|
|
249
|
+
// Both are re-emitted on roughly every turn rather than only when they change.
|
|
250
|
+
// Measured on the two largest transcripts on this machine: 685 `ai-title`
|
|
251
|
+
// entries carrying 2 DISTINCT values (46.4 MB session) and 406 carrying 1
|
|
252
|
+
// (19.6 MB session). So "the last one wins" is right, but the value is nearly
|
|
253
|
+
// always the value we already had — which is why the emit below is gated on a
|
|
254
|
+
// change rather than fired per pass.
|
|
255
|
+
//
|
|
256
|
+
// `aiTitle` is NOT reliably the sentence it looks like. In the 46.4 MB session
|
|
257
|
+
// the first 332 entries read "Inspect repository to understand current state"
|
|
258
|
+
// and every one of the last 353 reads "account-management-oauth-flow" —
|
|
259
|
+
// byte-identical to `agentName`, and 353 is exactly the `agent-name` count. CC
|
|
260
|
+
// overwrites the title with the slug once a session has a name. The client
|
|
261
|
+
// therefore has to treat "title equals name" as "no title", or the tooltip
|
|
262
|
+
// would repeat the card.
|
|
263
|
+
const AI_TITLE_MARK = '"ai-title"';
|
|
264
|
+
const AGENT_NAME_MARK = '"agent-name"';
|
|
265
|
+
|
|
266
|
+
/** Fold one line's naming records into `out`. Last value wins. Pure: `out` is
|
|
267
|
+
* the only thing written, and a line that is not one of the two records — or
|
|
268
|
+
* is a truncated fragment of one — leaves it untouched. */
|
|
269
|
+
function foldSessionNamingLine(out, line) {
|
|
270
|
+
if (!line) return;
|
|
271
|
+
const hasTitle = line.includes(AI_TITLE_MARK);
|
|
272
|
+
const hasName = line.includes(AGENT_NAME_MARK);
|
|
273
|
+
if (!hasTitle && !hasName) return;
|
|
274
|
+
let obj = null;
|
|
275
|
+
try { obj = JSON.parse(line); } catch { return; }
|
|
276
|
+
if (!obj || typeof obj !== "object") return;
|
|
277
|
+
if (obj.type === "ai-title" && typeof obj.aiTitle === "string" && obj.aiTitle) {
|
|
278
|
+
out.aiTitle = obj.aiTitle;
|
|
279
|
+
} else if (obj.type === "agent-name" && typeof obj.agentName === "string" && obj.agentName) {
|
|
280
|
+
out.agentName = obj.agentName;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The session naming carried by a chunk of transcript text, newest wins.
|
|
286
|
+
*
|
|
287
|
+
* Pure and text-in, so the suite can pin the parsing against a handful of lines
|
|
288
|
+
* instead of a 46 MB fixture. Returns `{aiTitle: null, agentName: null}` for a
|
|
289
|
+
* chunk holding neither — a young session, or a stretch of the file that is all
|
|
290
|
+
* tool output — and the caller keeps whatever it already knew rather than
|
|
291
|
+
* clearing a name it has already shown.
|
|
292
|
+
*/
|
|
293
|
+
export function readSessionNaming(text) {
|
|
294
|
+
const out = { aiTitle: null, agentName: null };
|
|
295
|
+
if (!text || typeof text !== "string") return out;
|
|
296
|
+
for (const line of text.split("\n")) foldSessionNamingLine(out, line);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Fold one transcript line into the running state. Every fact the three
|
|
301
|
+
* scanners need lives on a single line, so line-at-a-time folding sees
|
|
302
|
+
* exactly what a whole-file pass would. */
|
|
303
|
+
function foldTranscriptLine(state, line) {
|
|
304
|
+
if (!line) return;
|
|
305
|
+
|
|
306
|
+
// Rides the scan that is already reading these bytes for the model, the usage
|
|
307
|
+
// totals and the context counts, so naming costs no read of its own — see the
|
|
308
|
+
// block above `maybeResolveSessionName` for why that beat a tail read.
|
|
309
|
+
foldSessionNamingLine(state, line);
|
|
310
|
+
|
|
311
|
+
// Model. Only a line that mentions a model can change it, and parsing the
|
|
312
|
+
// rest is what made the full rescan expensive.
|
|
313
|
+
if (line.includes('"model"')) {
|
|
314
|
+
let obj = null;
|
|
315
|
+
try { obj = JSON.parse(line); } catch {}
|
|
316
|
+
const msg = obj && obj.message;
|
|
317
|
+
const model = (msg && typeof msg.model === "string" && MODEL_ID_RE.test(msg.model)) ? msg.model
|
|
318
|
+
: (obj && typeof obj.model === "string" && MODEL_ID_RE.test(obj.model)) ? obj.model
|
|
319
|
+
: null;
|
|
320
|
+
if (model) {
|
|
321
|
+
state.lastModel = model;
|
|
322
|
+
const isSide = obj.isSidechain === true || obj.is_sidechain === true;
|
|
323
|
+
const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
|
|
324
|
+
if (isSide && ptid) state.subagentModels[ptid] = model;
|
|
325
|
+
else if (!isSide) state.rootModel = model;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Usage totals sum every block in the file, resets included.
|
|
330
|
+
for (const m of line.matchAll(USAGE_BLOCK_RE)) {
|
|
331
|
+
const blob = m[1];
|
|
332
|
+
state.usage.input_tokens += grabUsageField(blob, "input_tokens");
|
|
333
|
+
state.usage.output_tokens += grabUsageField(blob, "output_tokens");
|
|
334
|
+
state.usage.cache_read_input_tokens += grabUsageField(blob, "cache_read_input_tokens");
|
|
335
|
+
state.usage.cache_creation_input_tokens += grabUsageField(blob, "cache_creation_input_tokens");
|
|
336
|
+
}
|
|
337
|
+
for (const m of line.matchAll(CACHE_CREATION_BLOCK_RE)) {
|
|
338
|
+
state.usage.ephemeral_1h_input_tokens += grabUsageField(m[1], "ephemeral_1h_input_tokens");
|
|
339
|
+
state.usage.ephemeral_5m_input_tokens += grabUsageField(m[1], "ephemeral_5m_input_tokens");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Context counts only what follows the most recent /clear or /compact.
|
|
343
|
+
let ctxText = line;
|
|
344
|
+
let resetEnd = -1;
|
|
345
|
+
for (const m of line.matchAll(CONTEXT_RESET_RE)) resetEnd = (m.index ?? -1) + m[0].length;
|
|
346
|
+
if (resetEnd >= 0) {
|
|
347
|
+
state.ctx = newContextBreakdown();
|
|
348
|
+
ctxText = line.slice(resetEnd);
|
|
349
|
+
}
|
|
350
|
+
const ctx = state.ctx;
|
|
351
|
+
ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
|
|
352
|
+
ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
|
|
353
|
+
ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
|
|
354
|
+
ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
|
|
355
|
+
ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
|
|
356
|
+
// Current context size = the LAST usage block after the reset. Stays 0
|
|
357
|
+
// right after a /clear, which is what CC's own /context reports.
|
|
358
|
+
let lastBlob = null;
|
|
359
|
+
for (const m of ctxText.matchAll(USAGE_BLOCK_RE)) lastBlob = m[1];
|
|
360
|
+
if (lastBlob) {
|
|
361
|
+
ctx.currentContextTokens =
|
|
362
|
+
grabUsageField(lastBlob, "input_tokens") +
|
|
363
|
+
grabUsageField(lastBlob, "cache_read_input_tokens") +
|
|
364
|
+
grabUsageField(lastBlob, "cache_creation_input_tokens");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Record a use of `path` and keep the map under the cap. Re-inserting on
|
|
369
|
+
* every touch makes the Map's own insertion order the LRU order, so eviction
|
|
370
|
+
* reads one key instead of scanning for the smallest timestamp. Scanning a
|
|
371
|
+
* timestamp is also what broke the cache: a state is created with no stamp
|
|
372
|
+
* yet, so the entry the scan had just inserted was the smallest of all and
|
|
373
|
+
* the one deleted, every time. Past 256 distinct transcripts the map froze on
|
|
374
|
+
* the first 256 paths and every later transcript re-read its whole JSONL from
|
|
375
|
+
* byte 0 on every throttled pass — the O(n)-per-pass stall the cursor exists
|
|
376
|
+
* to remove. */
|
|
377
|
+
function touchTranscriptScan(path, state) {
|
|
378
|
+
transcriptScans.delete(path);
|
|
379
|
+
transcriptScans.set(path, state);
|
|
380
|
+
pruneTranscriptScans();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function pruneTranscriptScans() {
|
|
384
|
+
while (transcriptScans.size > MAX_TRANSCRIPT_SCANS) {
|
|
385
|
+
transcriptScans.delete(transcriptScans.keys().next().value);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Bring a transcript's scan state up to date and return it. Concurrent
|
|
390
|
+
* callers share one read — folding the same appended bytes twice would
|
|
391
|
+
* double the usage totals. Never throws; an unreadable file just leaves
|
|
392
|
+
* the state where it was. */
|
|
393
|
+
function scanTranscript(path) {
|
|
394
|
+
if (!path || typeof path !== "string") return Promise.resolve(null);
|
|
395
|
+
const inFlight = transcriptScanInFlight.get(path);
|
|
396
|
+
if (inFlight) return inFlight;
|
|
397
|
+
const run = (async () => {
|
|
398
|
+
let state = transcriptScans.get(path);
|
|
399
|
+
if (!state) state = newTranscriptState();
|
|
400
|
+
touchTranscriptScan(path, state);
|
|
401
|
+
try {
|
|
402
|
+
const s = await stat(path);
|
|
403
|
+
// Shorter than the cursor means the file was truncated, rotated or
|
|
404
|
+
// replaced — the offset now points at unrelated bytes, so start over.
|
|
405
|
+
if (s.size < state.offset) {
|
|
406
|
+
state = newTranscriptState();
|
|
407
|
+
// Touch again rather than set: the await above yielded, and another
|
|
408
|
+
// path's scan may have re-ordered or evicted this entry meanwhile.
|
|
409
|
+
touchTranscriptScan(path, state);
|
|
410
|
+
}
|
|
411
|
+
if (s.size <= state.offset) return state;
|
|
412
|
+
const text = await readByteRange(path, state.offset, s.size);
|
|
413
|
+
const lastNl = text.lastIndexOf("\n");
|
|
414
|
+
if (lastNl < 0) return state; // no complete line appended yet
|
|
415
|
+
const consumed = text.slice(0, lastNl);
|
|
416
|
+
// Advance before folding: a fold that throws half-way must not leave
|
|
417
|
+
// the cursor where the next pass would count those lines again.
|
|
418
|
+
state.offset += Buffer.byteLength(consumed, "utf8") + 1; // +1 for the \n
|
|
419
|
+
for (const line of consumed.split("\n")) foldTranscriptLine(state, line);
|
|
420
|
+
} catch { /* keep whatever we already folded */ }
|
|
421
|
+
return state;
|
|
422
|
+
})();
|
|
423
|
+
transcriptScanInFlight.set(path, run);
|
|
424
|
+
return run.finally(() => {
|
|
425
|
+
if (transcriptScanInFlight.get(path) === run) transcriptScanInFlight.delete(path);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─── Model enrichment ────────────────────────────────────────────────────
|
|
430
|
+
// CC's hook payloads never carry the `model` field — but every hook
|
|
431
|
+
// references a `transcript_path` JSONL that contains lines like
|
|
432
|
+
// `"model":"claude-opus-4-7"`. We re-read the tail of that file on every
|
|
433
|
+
// event for the session (throttled to MODEL_READ_THROTTLE_MS), cache the
|
|
434
|
+
// resolved root + subagent models, and (a) inject `model` into subsequent
|
|
435
|
+
// payloads for that session before broadcasting, (b) emit a synthetic
|
|
436
|
+
// `ModelObserved` event whenever that set CHANGES, so the client backfills
|
|
437
|
+
// agents created before the model was resolved. The cache is the emit
|
|
438
|
+
// filter, not a read filter: reading once per session meant a subagent
|
|
439
|
+
// model that only appears after the root is known was never picked up.
|
|
440
|
+
const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
|
|
441
|
+
const pendingTranscriptReads = new Set(); // sessionId currently being read
|
|
442
|
+
const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
|
|
443
|
+
const MODEL_READ_THROTTLE_MS = 2500;
|
|
444
|
+
|
|
445
|
+
/** The cache entry is `{ rootModel, subsSig }`, but `payload.model` is a
|
|
446
|
+
* model *string* — that is the only shape the client's recursive scanner
|
|
447
|
+
* reads. Returns the root model id, or null when the session resolved
|
|
448
|
+
* only subagent models and has no root model yet. */
|
|
449
|
+
export function cachedModelId(cached) {
|
|
450
|
+
const rootModel = cached?.rootModel;
|
|
451
|
+
return typeof rootModel === "string" && rootModel ? rootModel : null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Read the main session JSONL. Returns the root model and any
|
|
455
|
+
* legacy-schema subagent models (older CC versions kept subagent blocks
|
|
456
|
+
* inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
|
|
457
|
+
* store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
|
|
458
|
+
* handled by `readSubagentModelsFromDir` below. */
|
|
459
|
+
export async function readModelFromTranscript(path) {
|
|
460
|
+
const state = await scanTranscript(path);
|
|
461
|
+
if (!state) return null;
|
|
462
|
+
const rootModel = state.rootModel ?? state.lastModel;
|
|
463
|
+
const subagentModels = { ...state.subagentModels };
|
|
464
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return null;
|
|
465
|
+
return { rootModel, subagentModels };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
|
|
469
|
+
* `<projects>/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl` with a
|
|
470
|
+
* sidecar `.meta.json` carrying `{agentType, description}`. The hook
|
|
471
|
+
* payload's `agent_id` matches the file's <agentId>, so the reducer can
|
|
472
|
+
* attribute via the existing `subagentModels` map (it keys by parentToolUseId
|
|
473
|
+
* but the reducer looks up `${sessionId}::${key}` and the subagent node id
|
|
474
|
+
* is built from `agent_id` — identical lookup either way).
|
|
475
|
+
*
|
|
476
|
+
* Returns { [agentId]: model } scanning every agent-*.jsonl file in dir. */
|
|
477
|
+
async function readSubagentModelsFromDir(transcriptPath) {
|
|
478
|
+
// Subagent dir sits next to the main jsonl: <dir>/<sessionId>/subagents/
|
|
479
|
+
// Derive from transcript_path by stripping the .jsonl suffix.
|
|
480
|
+
if (!transcriptPath || typeof transcriptPath !== "string") return null;
|
|
481
|
+
const sessionDir = transcriptPath.replace(/\.jsonl$/i, "");
|
|
482
|
+
const subDir = join(sessionDir, "subagents");
|
|
483
|
+
let entries;
|
|
484
|
+
try { entries = await readdir(subDir); } catch { return null; }
|
|
485
|
+
const models = {};
|
|
486
|
+
for (const f of entries) {
|
|
487
|
+
if (!/^agent-([0-9a-f]+)\.jsonl$/i.test(f)) continue;
|
|
488
|
+
const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
|
|
489
|
+
const full = join(subDir, f);
|
|
490
|
+
try {
|
|
491
|
+
// Same incremental cursor as the main transcript. Last-seen claude-*
|
|
492
|
+
// model wins — subagents may switch model mid-turn (Sonnet → Haiku for
|
|
493
|
+
// tool-call fallback etc.).
|
|
494
|
+
const state = await scanTranscript(full);
|
|
495
|
+
const last = state ? state.lastModel : null;
|
|
496
|
+
if (last) models[agentId] = last;
|
|
497
|
+
} catch { /* skip unreadable file */ }
|
|
498
|
+
}
|
|
499
|
+
return Object.keys(models).length ? models : null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function maybeResolveModel(payload) {
|
|
503
|
+
if (!payload || typeof payload !== "object") return;
|
|
504
|
+
const sid = payload.session_id;
|
|
505
|
+
const tp = payload.transcript_path;
|
|
506
|
+
if (!sid || !tp) return;
|
|
507
|
+
// Re-read on every event for this session — the cache was preventing us
|
|
508
|
+
// from picking up subagent models that arrive after the root is known.
|
|
509
|
+
// Throttle so we don't thrash the filesystem.
|
|
510
|
+
if (pendingTranscriptReads.has(sid)) return;
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
const last = modelLastReadAt.get(sid) ?? 0;
|
|
513
|
+
if (now - last < MODEL_READ_THROTTLE_MS) return;
|
|
514
|
+
modelLastReadAt.set(sid, now);
|
|
515
|
+
pendingTranscriptReads.add(sid);
|
|
516
|
+
Promise.all([readModelFromTranscript(tp), readSubagentModelsFromDir(tp)])
|
|
517
|
+
.then(([result, dirSubs]) => {
|
|
518
|
+
const rootModel = result?.rootModel ?? null;
|
|
519
|
+
// Merge legacy (inline isSidechain) + new (subagents/ dir) maps. Dir
|
|
520
|
+
// wins on conflict since current CC only writes to the dir.
|
|
521
|
+
const subagentModels = { ...(result?.subagentModels ?? {}), ...(dirSubs ?? {}) };
|
|
522
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return;
|
|
523
|
+
const prev = modelBySession.get(sid);
|
|
524
|
+
const subsSig = JSON.stringify(subagentModels);
|
|
525
|
+
if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
|
|
526
|
+
modelBySession.set(sid, { rootModel, subsSig });
|
|
527
|
+
pushEvent({
|
|
528
|
+
hook_event_name: "ModelObserved",
|
|
529
|
+
session_id: sid,
|
|
530
|
+
model: rootModel,
|
|
531
|
+
subagentModels,
|
|
532
|
+
}, "internal");
|
|
533
|
+
})
|
|
534
|
+
.catch(() => {})
|
|
535
|
+
.finally(() => pendingTranscriptReads.delete(sid));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ─── Usage enrichment ────────────────────────────────────────────────────
|
|
539
|
+
// Same story as the model: token counts (input/output/cache) are missing
|
|
540
|
+
// from every CC hook payload but present on every assistant message in
|
|
541
|
+
// the transcript JSONL as a `"usage":{…}` block. We sum them across the
|
|
542
|
+
// whole transcript and ship a synthetic UsageObserved event so the
|
|
543
|
+
// session's root agent gets accurate cumulative usage (and therefore the
|
|
544
|
+
// cost columns actually have something to multiply by).
|
|
545
|
+
const lastUsageReadAt = new Map(); // sid -> ms timestamp
|
|
546
|
+
const pendingUsageReads = new Set(); // sid currently being read
|
|
547
|
+
const USAGE_READ_THROTTLE_MS = 2500;
|
|
548
|
+
|
|
549
|
+
// Every entry carries its own usage object and we sum every occurrence, so
|
|
550
|
+
// the totals are cumulative over the whole transcript — the running state
|
|
551
|
+
// keeps them across passes and each pass only adds the newly appended blocks.
|
|
552
|
+
export async function readUsageFromTranscript(path) {
|
|
553
|
+
const state = await scanTranscript(path);
|
|
554
|
+
if (!state) return null;
|
|
555
|
+
const totals = { ...state.usage };
|
|
556
|
+
if (totals.input_tokens === 0 && totals.output_tokens === 0
|
|
557
|
+
&& totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
|
|
558
|
+
return totals;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function maybeResolveUsage(payload) {
|
|
562
|
+
if (!payload || typeof payload !== "object") return;
|
|
563
|
+
const sid = payload.session_id;
|
|
564
|
+
const tp = payload.transcript_path;
|
|
565
|
+
if (!sid || !tp) return;
|
|
566
|
+
if (pendingUsageReads.has(sid)) return;
|
|
567
|
+
const now = Date.now();
|
|
568
|
+
const last = lastUsageReadAt.get(sid) ?? 0;
|
|
569
|
+
if (now - last < USAGE_READ_THROTTLE_MS) return;
|
|
570
|
+
lastUsageReadAt.set(sid, now);
|
|
571
|
+
pendingUsageReads.add(sid);
|
|
572
|
+
readUsageFromTranscript(tp)
|
|
573
|
+
.then(usage => {
|
|
574
|
+
if (!usage) return;
|
|
575
|
+
pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage }, "internal");
|
|
576
|
+
})
|
|
577
|
+
.catch(() => {})
|
|
578
|
+
.finally(() => pendingUsageReads.delete(sid));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ─── Session-name enrichment ─────────────────────────────────────────────
|
|
582
|
+
// WHY THIS IS NOT A TAIL READ. The obvious shape for "get the newest naming
|
|
583
|
+
// record out of a 46 MB file" is to read the last N KB and parse backwards, and
|
|
584
|
+
// it is the wrong shape here for two reasons, one of them measured.
|
|
585
|
+
//
|
|
586
|
+
// The measured one: the density is not uniform, so no N is safe. Back-scanning
|
|
587
|
+
// from an arbitrary point in the 46.4 MB transcript to the nearest `ai-title`
|
|
588
|
+
// costs p50 38 KB but p95 227 KB, p99 511 KB and 723 KB worst case, because a
|
|
589
|
+
// single line in that file reaches 710 KB — one big tool result evicts every
|
|
590
|
+
// naming record from any window you picked. A 256 KB tail covers 95.9% of
|
|
591
|
+
// positions, 512 KB covers 99.1%, and the last percent still needs a megabyte.
|
|
592
|
+
//
|
|
593
|
+
// The structural one: it would be a second reader of bytes this process is
|
|
594
|
+
// already reading. `scanTranscript` keeps a per-path CURSOR and folds each
|
|
595
|
+
// appended line exactly once, for the model, the usage totals and the context
|
|
596
|
+
// counts alike. Folding two more fields into that pass (see
|
|
597
|
+
// `foldSessionNamingLine`) costs no read at all, sees every record rather than
|
|
598
|
+
// a window of them, and cannot be defeated by a 710 KB line.
|
|
599
|
+
//
|
|
600
|
+
// That also settles the trigger. With a tail read the trigger IS the cost
|
|
601
|
+
// control, so you have to pick a boundary and `Stop` is the honest one. With a
|
|
602
|
+
// cursor the bytes are read once whoever asks, so the trigger only decides how
|
|
603
|
+
// LATE the name appears — and gating on `Stop` would hold a name the scan
|
|
604
|
+
// already has until the turn ends, for a saving of zero. So this runs off any
|
|
605
|
+
// hook event like its three neighbours, throttled per session, and the throttle
|
|
606
|
+
// matches MODEL_READ_THROTTLE_MS so the two passes coincide and share one
|
|
607
|
+
// in-flight scan.
|
|
608
|
+
//
|
|
609
|
+
// The emit is what is actually kept rare, and it is gated on a CHANGE: with 685
|
|
610
|
+
// records carrying 2 distinct values, a per-pass emit would be ~683 events
|
|
611
|
+
// saying nothing. Sessions that never get named emit nothing at all.
|
|
612
|
+
const nameBySession = new Map(); // sid -> `${agentName}\0${aiTitle}`
|
|
613
|
+
const lastNameReadAt = new Map(); // sid -> ms timestamp
|
|
614
|
+
const pendingNameReads = new Set(); // sid currently being read
|
|
615
|
+
|
|
616
|
+
/** The naming the cursor has folded so far, or null when the scan has nothing.
|
|
617
|
+
* Exported beside readContextFromTranscript for the same reason: the rule is
|
|
618
|
+
* worth pinning directly rather than through a live server. */
|
|
619
|
+
export async function readSessionNamingFromTranscript(path) {
|
|
620
|
+
const state = await scanTranscript(path);
|
|
621
|
+
if (!state) return null;
|
|
622
|
+
if (!state.agentName && !state.aiTitle) return null;
|
|
623
|
+
return { agentName: state.agentName, aiTitle: state.aiTitle };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function maybeResolveSessionName(payload) {
|
|
627
|
+
if (!payload || typeof payload !== "object") return;
|
|
628
|
+
const sid = payload.session_id;
|
|
629
|
+
const tp = payload.transcript_path;
|
|
630
|
+
if (!sid || !tp) return;
|
|
631
|
+
if (pendingNameReads.has(sid)) return;
|
|
632
|
+
const now = Date.now();
|
|
633
|
+
const last = lastNameReadAt.get(sid) ?? 0;
|
|
634
|
+
if (now - last < MODEL_READ_THROTTLE_MS) return;
|
|
635
|
+
lastNameReadAt.set(sid, now);
|
|
636
|
+
pendingNameReads.add(sid);
|
|
637
|
+
readSessionNamingFromTranscript(tp)
|
|
638
|
+
.then(naming => {
|
|
639
|
+
if (!naming) return;
|
|
640
|
+
const sig = `${naming.agentName ?? ""}\u0000${naming.aiTitle ?? ""}`;
|
|
641
|
+
if (nameBySession.get(sid) === sig) return;
|
|
642
|
+
nameBySession.set(sid, sig);
|
|
643
|
+
pushEvent({
|
|
644
|
+
hook_event_name: "SessionNamed",
|
|
645
|
+
session_id: sid,
|
|
646
|
+
sessionName: naming.agentName ?? null,
|
|
647
|
+
sessionTitle: naming.aiTitle ?? null,
|
|
648
|
+
}, "internal");
|
|
649
|
+
})
|
|
650
|
+
.catch(() => {})
|
|
651
|
+
.finally(() => pendingNameReads.delete(sid));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ─── Context enrichment ──────────────────────────────────────────────────
|
|
655
|
+
// Approximation of `/context` since CC doesn't expose its breakdown via
|
|
656
|
+
// hooks. We scan the transcript JSONL for message counts (user / assistant
|
|
657
|
+
// / tool_use / tool_result / system-reminders) and walk up from cwd for
|
|
658
|
+
// any CLAUDE.md files in scope. Cumulative token totals come from
|
|
659
|
+
// UsageObserved; this scan produces the structural counts ("what does the
|
|
660
|
+
// context contain") plus the current window size — currentContextTokens, the
|
|
661
|
+
// last usage block after the most recent /clear or /compact, which is what
|
|
662
|
+
// the context donut and the modal's percentage are drawn from.
|
|
663
|
+
const lastContextReadAt = new Map();
|
|
664
|
+
const pendingContextReads = new Set();
|
|
665
|
+
const CONTEXT_READ_THROTTLE_MS = 4000;
|
|
666
|
+
|
|
667
|
+
// The counts reset at every `/clear` or `/compact` marker (see
|
|
668
|
+
// foldTranscriptLine): CC resets its in-memory window there while the JSONL
|
|
669
|
+
// keeps growing, and reading the pre-reset blocks made the donut report ~100%
|
|
670
|
+
// on an empty context.
|
|
671
|
+
export async function readContextFromTranscript(path) {
|
|
672
|
+
const state = await scanTranscript(path);
|
|
673
|
+
// Nothing folded yet — the file is empty, unreadable, or has no complete
|
|
674
|
+
// line. Callers treat that as "no breakdown", same as before.
|
|
675
|
+
if (!state || state.offset === 0) return null;
|
|
676
|
+
return { ...state.ctx };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** Longest slug CC will store verbatim. Anything longer is truncated here and
|
|
680
|
+
* given a hash suffix, so two deep paths sharing a 200-character prefix still
|
|
681
|
+
* get separate directories. */
|
|
682
|
+
const CC_SLUG_MAX = 200;
|
|
683
|
+
|
|
684
|
+
/** CC's hash of the *unencoded* path, used only for the truncation suffix:
|
|
685
|
+
* the classic h*31 + c string hash, kept in a signed 32-bit int. */
|
|
686
|
+
function ccPathHash(s) {
|
|
687
|
+
let h = 0;
|
|
688
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
689
|
+
return h;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Encode an absolute path the way CC stores it under
|
|
693
|
+
* ~/.claude/projects/<slug>/, so the auto-memory scan below looks in the
|
|
694
|
+
* directory CC actually wrote.
|
|
695
|
+
*
|
|
696
|
+
* CC flattens *every* non-alphanumeric character to "-", not just the path
|
|
697
|
+
* separators and the Windows drive colon this used to replace. Dots are the
|
|
698
|
+
* ones that bite: a worktree under .claude/worktrees/, or any folder named
|
|
699
|
+
* like my.app, produced a slug with a literal dot in it, the readdir below
|
|
700
|
+
* missed, and the project's auto-memory files were quietly left out of the
|
|
701
|
+
* context panel. Underscores and spaces were wrong the same way. */
|
|
702
|
+
export function ccProjectSlug(cwd) {
|
|
703
|
+
if (!cwd) return "";
|
|
704
|
+
// resolve() gives the platform's own absolute form — "/Users/…" on
|
|
705
|
+
// macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
|
|
706
|
+
// drive colon and the backslashes fall out of the general rule.
|
|
707
|
+
const abs = resolve(cwd);
|
|
708
|
+
const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
|
|
709
|
+
if (slug.length <= CC_SLUG_MAX) return slug;
|
|
710
|
+
return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
|
|
715
|
+
*
|
|
716
|
+
* Both CLIs load their memory file the same way — nearest-first from the
|
|
717
|
+
* working directory outwards — and differ only in what the file is CALLED and
|
|
718
|
+
* in what else they add on top, so the walk is written once here and the two
|
|
719
|
+
* scanners below supply their own names. `rels` is a list of paths RELATIVE to
|
|
720
|
+
* each directory on the walk rather than bare filenames, because CC also honours
|
|
721
|
+
* `.claude/CLAUDE.md` at every level and Codex does not.
|
|
722
|
+
*
|
|
723
|
+
* Sixteen levels is the same depth this has always used: deep enough for any
|
|
724
|
+
* real checkout, shallow enough that a cwd on a network mount cannot turn one
|
|
725
|
+
* context read into an unbounded number of stat() calls.
|
|
726
|
+
*
|
|
727
|
+
* Returns paths without touching the disk. Statting them is collectMemoryFiles'
|
|
728
|
+
* job, so a caller that wants to add its own paths — a user-global file, a
|
|
729
|
+
* per-project memory directory — can splice them into one ordered list and get
|
|
730
|
+
* a single de-duplicated, existence-checked answer back.
|
|
731
|
+
*/
|
|
732
|
+
function memoryWalkPaths(cwd, rels) {
|
|
733
|
+
const out = [];
|
|
734
|
+
let dir = resolve(cwd);
|
|
735
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
736
|
+
for (const rel of rels) out.push(join(dir, rel));
|
|
737
|
+
const parent = pdirname(dir);
|
|
738
|
+
if (parent === dir) break;
|
|
739
|
+
dir = parent;
|
|
740
|
+
}
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Which of `paths` are real, non-empty files, in the order given and with
|
|
746
|
+
* duplicates dropped.
|
|
747
|
+
*
|
|
748
|
+
* A zero-byte file is skipped on purpose: it contributes nothing to the model's
|
|
749
|
+
* context, and listing it in the modal would have the reader looking for the
|
|
750
|
+
* bytes it claims to cost. A path that cannot be stat()ed is simply absent —
|
|
751
|
+
* this runs against a tree another process is editing, and a permissions error
|
|
752
|
+
* on one candidate is no reason to lose the other fifteen.
|
|
753
|
+
*/
|
|
754
|
+
async function collectMemoryFiles(paths) {
|
|
755
|
+
const found = [];
|
|
756
|
+
const seen = new Set();
|
|
757
|
+
for (const p of paths) {
|
|
758
|
+
if (seen.has(p)) continue;
|
|
759
|
+
seen.add(p);
|
|
760
|
+
try {
|
|
761
|
+
const s = await stat(p);
|
|
762
|
+
if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
|
|
763
|
+
} catch {}
|
|
764
|
+
}
|
|
765
|
+
return found;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** The memory files a CLAUDE session has in scope. Exported alongside its Codex
|
|
769
|
+
* counterpart below so a test can check the pair together — the two must agree
|
|
770
|
+
* on the walk and disagree on the filename, and only one of them showing up in
|
|
771
|
+
* a test is how they would drift. */
|
|
772
|
+
export async function scanClaudeMdFiles(cwd) {
|
|
773
|
+
if (!cwd || typeof cwd !== "string") return [];
|
|
774
|
+
const home = homedir();
|
|
775
|
+
// Walk up from cwd to filesystem root, checking the canonical CC memory
|
|
776
|
+
// filenames plus CLAUDE.local.md (user-private) at each level.
|
|
777
|
+
const paths = memoryWalkPaths(cwd, [
|
|
778
|
+
"CLAUDE.md",
|
|
779
|
+
"CLAUDE.local.md",
|
|
780
|
+
join(".claude", "CLAUDE.md"),
|
|
781
|
+
join(".claude", "CLAUDE.local.md"),
|
|
782
|
+
]);
|
|
783
|
+
// User-global memory.
|
|
784
|
+
paths.push(join(home, ".claude", "CLAUDE.md"));
|
|
785
|
+
paths.push(join(home, ".claude", "CLAUDE.local.md"));
|
|
786
|
+
// Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
|
|
787
|
+
// (plus MEMORY.md index). CC injects these into context for sessions
|
|
788
|
+
// whose cwd matches the slug.
|
|
789
|
+
const slug = ccProjectSlug(cwd);
|
|
790
|
+
if (slug) {
|
|
791
|
+
const memDir = join(home, ".claude", "projects", slug, "memory");
|
|
792
|
+
try {
|
|
793
|
+
const entries = await readdir(memDir);
|
|
794
|
+
for (const f of entries) {
|
|
795
|
+
if (f.toLowerCase().endsWith(".md")) paths.push(join(memDir, f));
|
|
796
|
+
}
|
|
797
|
+
} catch {}
|
|
798
|
+
}
|
|
799
|
+
return collectMemoryFiles(paths);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The memory files a CODEX session has in scope: AGENTS.md, not CLAUDE.md.
|
|
804
|
+
*
|
|
805
|
+
* WHY THIS FUNCTION EXISTS AT ALL (#399). The context modal's third section was
|
|
806
|
+
* fed by scanClaudeMdFiles for every session regardless of provider, so the
|
|
807
|
+
* moment the donut became reachable for Codex the modal would have told a Codex
|
|
808
|
+
* user "No CLAUDE.md files found on the path from cwd to ~/.claude" — naming a
|
|
809
|
+
* file and a directory Codex does not read. Before this, `AGENTS.md` did not
|
|
810
|
+
* appear anywhere in this repository.
|
|
811
|
+
*
|
|
812
|
+
* That Codex reads it is not an assumption. Sampled every rollout under this
|
|
813
|
+
* machine's CODEX_HOME (structural search, no record content printed): the
|
|
814
|
+
* literal string `AGENTS.md` appears on 9 lines across 5 of the 8 files — in the
|
|
815
|
+
* `response_item/message` role=user preamble Codex prepends to a turn, in a
|
|
816
|
+
* role=developer message, and in a `world_state` record — while `CLAUDE.md`
|
|
817
|
+
* appears on zero lines in any of them.
|
|
818
|
+
*
|
|
819
|
+
* WHY THE FILESYSTEM AND NOT THE ROLLOUT. The rollout does name the files, but
|
|
820
|
+
* only inside message TEXT, and reading the text of a user's conversation to
|
|
821
|
+
* find a filename is not a trade this deck makes anywhere else — the Claude side
|
|
822
|
+
* has always answered the same question by walking the filesystem, and the two
|
|
823
|
+
* halves of one modal section should be derived the same way or the reader
|
|
824
|
+
* cannot compare them.
|
|
825
|
+
*
|
|
826
|
+
* CODEX_HOME comes from codex-dir.mjs like every other Codex path in the
|
|
827
|
+
* process (#375), so a relocated Codex home is honoured here without this
|
|
828
|
+
* module growing a sixth spelling of the rule.
|
|
829
|
+
*
|
|
830
|
+
* Exported for the tests, like readContextFromTranscript beside it: the rule for
|
|
831
|
+
* which files a session has in scope is worth pinning directly, rather than
|
|
832
|
+
* through a watcher, a temp home and a 1.5s poll.
|
|
833
|
+
*/
|
|
834
|
+
export async function scanAgentsMdFiles(cwd) {
|
|
835
|
+
if (!cwd || typeof cwd !== "string") return [];
|
|
836
|
+
// Codex has no `.codex/AGENTS.md` per-directory convention to mirror CC's
|
|
837
|
+
// `.claude/CLAUDE.md`, so the per-level list is the single filename.
|
|
838
|
+
const paths = memoryWalkPaths(cwd, ["AGENTS.md"]);
|
|
839
|
+
// The user-global instructions file, which Codex loads for every session
|
|
840
|
+
// whatever the cwd — the counterpart of ~/.claude/CLAUDE.md.
|
|
841
|
+
paths.push(join(CODEX_HOME, "AGENTS.md"));
|
|
842
|
+
return collectMemoryFiles(paths);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function maybeResolveContext(payload) {
|
|
846
|
+
if (!payload || typeof payload !== "object") return;
|
|
847
|
+
const sid = payload.session_id;
|
|
848
|
+
const tp = payload.transcript_path;
|
|
849
|
+
const cwd = payload.cwd;
|
|
850
|
+
if (!sid || !tp) return;
|
|
851
|
+
if (pendingContextReads.has(sid)) return;
|
|
852
|
+
const now = Date.now();
|
|
853
|
+
const last = lastContextReadAt.get(sid) ?? 0;
|
|
854
|
+
if (now - last < CONTEXT_READ_THROTTLE_MS) return;
|
|
855
|
+
lastContextReadAt.set(sid, now);
|
|
856
|
+
pendingContextReads.add(sid);
|
|
857
|
+
Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
|
|
858
|
+
.then(([breakdown, memoryFiles]) => {
|
|
859
|
+
if (!breakdown && (!memoryFiles || memoryFiles.length === 0)) return;
|
|
860
|
+
pushEvent({
|
|
861
|
+
hook_event_name: "ContextObserved",
|
|
862
|
+
session_id: sid,
|
|
863
|
+
context: {
|
|
864
|
+
...(breakdown ?? {}),
|
|
865
|
+
memoryFiles: memoryFiles ?? [],
|
|
866
|
+
},
|
|
867
|
+
}, "internal");
|
|
868
|
+
})
|
|
869
|
+
.catch(() => {})
|
|
870
|
+
.finally(() => pendingContextReads.delete(sid));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Throttle state for the Codex half of the same question. Separate maps rather
|
|
874
|
+
// than sharing maybeResolveContext's, because the two run on different triggers
|
|
875
|
+
// — a hook payload there, a batch of appended rollout lines here — and one
|
|
876
|
+
// session cannot be both.
|
|
877
|
+
const lastCodexMemoryReadAt = new Map();
|
|
878
|
+
const pendingCodexMemoryReads = new Set();
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Emit the memory files a Codex session has in scope, throttled per session.
|
|
882
|
+
*
|
|
883
|
+
* WHY THIS IS NOT maybeResolveContext (#399). That function early-returns
|
|
884
|
+
* without `payload.transcript_path`, a field only Claude Code sends, and it
|
|
885
|
+
* would scan for CLAUDE.md if it got that far. It is also unreachable from here
|
|
886
|
+
* on a second count: pushEvent gates all of its enrichment on `source ===
|
|
887
|
+
* "hook"`, and the Codex rollout watcher emits with source "codex" because it is
|
|
888
|
+
* not a hook stream at all. So this is called from the watcher's own scan loop,
|
|
889
|
+
* next to the lazy root, rather than off the back of an event.
|
|
890
|
+
*
|
|
891
|
+
* There is no structural breakdown alongside the file list, and that is the
|
|
892
|
+
* honest answer rather than a gap: the counts the Claude side reports —
|
|
893
|
+
* user/assistant messages, tool uses, tool results, system-reminders — come from
|
|
894
|
+
* a regex scan of a transcript this deck has read from byte zero, and the
|
|
895
|
+
* watcher deliberately SKIPS a pre-existing session's history at startup
|
|
896
|
+
* (`state.offset = st.size`). Counting from the moment the deck attached would
|
|
897
|
+
* produce five confident numbers that are all short by however much of the
|
|
898
|
+
* session happened first, on a panel whose whole purpose is to say what is in
|
|
899
|
+
* the window. ContextModal says so in the slot those counts would have used;
|
|
900
|
+
* see codex-approval.ts for the same move on a different unanswerable question.
|
|
901
|
+
*
|
|
902
|
+
* WHY `persist` IS A PARAMETER (#447). Every other event the rollout watcher
|
|
903
|
+
* produces goes out through emitCodexEvent, which carries the per-batch verdict
|
|
904
|
+
* of writesCodexLog: several decks tailing one rollout all DRAW it, and exactly
|
|
905
|
+
* one of them appends it to the events.jsonl they share. This function pushed
|
|
906
|
+
* with no opts at all, so pushEvent fell back to writesLogFor — and that answers
|
|
907
|
+
* from `foreignSessions`, a set only ever filled by noteLogWriter off an
|
|
908
|
+
* incoming hook POST marked `?persist=0`. A Codex rollout never touches an HTTP
|
|
909
|
+
* handler and Codex hooks are not installed any more (installer.mjs keeps the
|
|
910
|
+
* provider for uninstall only), so a Codex session id can never appear in that
|
|
911
|
+
* set and every deck answered "yes, mine" for this one event. The caller already
|
|
912
|
+
* holds the verdict for the whole batch, so it is threaded in rather than
|
|
913
|
+
* re-derived: recomputing it here would cost a second directory listing and
|
|
914
|
+
* could disagree with the roots and tool calls the same batch just emitted.
|
|
915
|
+
*
|
|
916
|
+
* Omitting the argument keeps the old behaviour — write. That is the same
|
|
917
|
+
* fail-safe writesCodexLog itself takes when a deck cannot find its own
|
|
918
|
+
* discovery record: a line written twice is recoverable, a deck that quietly
|
|
919
|
+
* records nothing is not.
|
|
920
|
+
*/
|
|
921
|
+
function maybeResolveCodexMemory(sid, cwd, persist) {
|
|
922
|
+
if (!sid || !cwd) return;
|
|
923
|
+
if (pendingCodexMemoryReads.has(sid)) return;
|
|
924
|
+
const now = Date.now();
|
|
925
|
+
const last = lastCodexMemoryReadAt.get(sid) ?? 0;
|
|
926
|
+
if (now - last < CONTEXT_READ_THROTTLE_MS) return;
|
|
927
|
+
lastCodexMemoryReadAt.set(sid, now);
|
|
928
|
+
pendingCodexMemoryReads.add(sid);
|
|
929
|
+
scanAgentsMdFiles(cwd)
|
|
930
|
+
.then(memoryFiles => {
|
|
931
|
+
// Nothing found is not a fact worth an event: the reducer merges a
|
|
932
|
+
// ContextObserved into whatever the session already had, and an empty list
|
|
933
|
+
// would only ever overwrite a real one with nothing. A repo that grows its
|
|
934
|
+
// first AGENTS.md mid-session is picked up by the next throttled pass.
|
|
935
|
+
if (!memoryFiles.length) return;
|
|
936
|
+
// `persist` false means another deck tailing this same rollout was elected
|
|
937
|
+
// to record it. The event is still buffered and broadcast from here, so
|
|
938
|
+
// every deck's context modal lists the AGENTS.md files — it is only the
|
|
939
|
+
// second copy on disk that is dropped, exactly as emitCodexEvent does it.
|
|
940
|
+
pushEvent({
|
|
941
|
+
hook_event_name: "ContextObserved",
|
|
942
|
+
session_id: sid,
|
|
943
|
+
provider: "codex",
|
|
944
|
+
context: { memoryFiles },
|
|
945
|
+
}, "internal", { persist });
|
|
946
|
+
})
|
|
947
|
+
.catch(() => {})
|
|
948
|
+
.finally(() => pendingCodexMemoryReads.delete(sid));
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ─── Codex transcript enrichment ──────────────────────────────────────────
|
|
952
|
+
// Codex CLI hook payloads carry `session_id` but no transcript path. Sessions
|
|
953
|
+
// are persisted to ~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl with one
|
|
954
|
+
// JSON object per line: {type, payload}. Token usage shows up in
|
|
955
|
+
// {type:"event_msg", payload:{type:"token_count",
|
|
956
|
+
// info:{total_token_usage:{input_tokens, cached_input_tokens,
|
|
957
|
+
// output_tokens, reasoning_output_tokens,
|
|
958
|
+
// total_tokens}}}}
|
|
959
|
+
// We resolve the rollout path lazily (cache sid→path), then read the tail
|
|
960
|
+
// for usage + model. CODEX_HOME overrides ~/.codex, and codex-dir.mjs owns that
|
|
961
|
+
// rule for every module on the Codex side — this one used to spell it inline,
|
|
962
|
+
// which is how five modules ended up with three spellings of it (#375).
|
|
963
|
+
//
|
|
964
|
+
// Re-exported because bin/deck.js prints this path in the boot banner, and it
|
|
965
|
+
// used to build its own `join(homedir(), ".codex", "sessions")` for the purpose
|
|
966
|
+
// — which ignored CODEX_HOME and so named a directory that does not exist on any
|
|
967
|
+
// machine that sets it. Handing out the binding the watcher itself reads, rather
|
|
968
|
+
// than a second computation of the same rule, is what makes the printed path and
|
|
969
|
+
// the tailed path unable to disagree.
|
|
970
|
+
export { CODEX_SESSIONS_DIR };
|
|
971
|
+
const codexRolloutPathBySid = new Map();
|
|
972
|
+
const lastCodexUsageReadAt = new Map();
|
|
973
|
+
const pendingCodexUsageReads = new Set();
|
|
974
|
+
const CODEX_READ_THROTTLE_MS = 2500;
|
|
975
|
+
|
|
976
|
+
async function findCodexRolloutPath(sid) {
|
|
977
|
+
const cached = codexRolloutPathBySid.get(sid);
|
|
978
|
+
if (cached) return cached;
|
|
979
|
+
// Walk year → month → day → files, newest first. Codex includes the sid in the
|
|
980
|
+
// filename (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
|
|
981
|
+
// The walk itself lives in codex-dir.mjs, shared with the watcher's listing
|
|
982
|
+
// below and with codex-usage.mjs, so all three read one tree the same way.
|
|
983
|
+
let found = null;
|
|
984
|
+
await walkRolloutDays((dayDir, files) => {
|
|
985
|
+
const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
|
|
986
|
+
if (!hit) return;
|
|
987
|
+
found = join(dayDir, hit);
|
|
988
|
+
return STOP;
|
|
989
|
+
});
|
|
990
|
+
if (found) codexRolloutPathBySid.set(sid, found);
|
|
991
|
+
return found;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Tail-read a Codex rollout JSONL. Returns the last token_count info block
|
|
995
|
+
* plus the most recent observed model + the session's model_context_window
|
|
996
|
+
* (set once from task_started). */
|
|
997
|
+
async function readCodexRollout(path) {
|
|
998
|
+
try {
|
|
999
|
+
const s = await stat(path);
|
|
1000
|
+
if (s.size === 0) return null;
|
|
1001
|
+
const fh = await open(path, "r");
|
|
1002
|
+
let text;
|
|
1003
|
+
try {
|
|
1004
|
+
const buf = Buffer.alloc(s.size);
|
|
1005
|
+
await fh.read(buf, 0, s.size, 0);
|
|
1006
|
+
text = buf.toString("utf8");
|
|
1007
|
+
} finally {
|
|
1008
|
+
await fh.close();
|
|
1009
|
+
}
|
|
1010
|
+
let lastUsage = null;
|
|
1011
|
+
let model = null;
|
|
1012
|
+
let contextWindow = null;
|
|
1013
|
+
let cwd = null;
|
|
1014
|
+
for (const line of text.split("\n")) {
|
|
1015
|
+
if (!line) continue;
|
|
1016
|
+
let obj;
|
|
1017
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
1018
|
+
const type = obj && obj.type;
|
|
1019
|
+
const pl = obj && obj.payload;
|
|
1020
|
+
if (type === "session_meta" && pl) {
|
|
1021
|
+
if (typeof pl.cwd === "string") cwd = pl.cwd;
|
|
1022
|
+
// session_meta sometimes carries the model in newer Codex versions.
|
|
1023
|
+
if (typeof pl.model === "string") model = pl.model;
|
|
1024
|
+
} else if (type === "event_msg" && pl) {
|
|
1025
|
+
if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
|
|
1026
|
+
lastUsage = pl.info.total_token_usage;
|
|
1027
|
+
} else if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
|
|
1028
|
+
contextWindow = pl.model_context_window;
|
|
1029
|
+
}
|
|
1030
|
+
} else if (type === "response_item" && pl && typeof pl.model === "string") {
|
|
1031
|
+
// Fallback model source — response items carry the model id.
|
|
1032
|
+
model = pl.model;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (!lastUsage && !model && !contextWindow) return null;
|
|
1036
|
+
return { usage: lastUsage, model, contextWindow, cwd };
|
|
1037
|
+
} catch {
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function maybeResolveCodex(payload) {
|
|
1043
|
+
if (!payload || typeof payload !== "object") return;
|
|
1044
|
+
if (payload.provider !== "codex") return;
|
|
1045
|
+
const sid = payload.session_id;
|
|
1046
|
+
if (!sid) return;
|
|
1047
|
+
if (pendingCodexUsageReads.has(sid)) return;
|
|
1048
|
+
const now = Date.now();
|
|
1049
|
+
const last = lastCodexUsageReadAt.get(sid) ?? 0;
|
|
1050
|
+
if (now - last < CODEX_READ_THROTTLE_MS) return;
|
|
1051
|
+
lastCodexUsageReadAt.set(sid, now);
|
|
1052
|
+
pendingCodexUsageReads.add(sid);
|
|
1053
|
+
(async () => {
|
|
1054
|
+
const path = await findCodexRolloutPath(sid);
|
|
1055
|
+
if (!path) return;
|
|
1056
|
+
const r = await readCodexRollout(path);
|
|
1057
|
+
if (!r) return;
|
|
1058
|
+
if (r.usage) {
|
|
1059
|
+
pushEvent({
|
|
1060
|
+
hook_event_name: "UsageObserved",
|
|
1061
|
+
session_id: sid,
|
|
1062
|
+
usage: r.usage,
|
|
1063
|
+
}, "internal");
|
|
1064
|
+
}
|
|
1065
|
+
if (r.model) {
|
|
1066
|
+
pushEvent({
|
|
1067
|
+
hook_event_name: "ModelObserved",
|
|
1068
|
+
session_id: sid,
|
|
1069
|
+
model: r.model,
|
|
1070
|
+
}, "internal");
|
|
1071
|
+
}
|
|
1072
|
+
if (r.contextWindow) {
|
|
1073
|
+
// Piggy-back on the model event with the window — reducer reads
|
|
1074
|
+
// model_context_window directly off any payload.
|
|
1075
|
+
pushEvent({
|
|
1076
|
+
hook_event_name: "ModelObserved",
|
|
1077
|
+
session_id: sid,
|
|
1078
|
+
model: r.model ?? undefined,
|
|
1079
|
+
model_context_window: r.contextWindow,
|
|
1080
|
+
}, "internal");
|
|
1081
|
+
}
|
|
1082
|
+
})()
|
|
1083
|
+
.catch(() => {})
|
|
1084
|
+
.finally(() => pendingCodexUsageReads.delete(sid));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ─── Codex rollout watcher ────────────────────────────────────────────────
|
|
1088
|
+
// Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
|
|
1089
|
+
// refuses to spawn the hook command (exit 1, child never runs). So instead of
|
|
1090
|
+
// relying on hooks, we tail the rollout JSONL files Codex writes to
|
|
1091
|
+
// ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl and reconstruct the
|
|
1092
|
+
// agent-dag event stream from them. Each rollout line is one append-only JSON
|
|
1093
|
+
// object {timestamp, type, payload}; we map the relevant ones to the same
|
|
1094
|
+
// synthetic hook payloads the reducer already understands:
|
|
1095
|
+
// session_meta → SessionStart
|
|
1096
|
+
// event_msg/user_message → UserPromptSubmit (Codex ≤ 0.144)
|
|
1097
|
+
// event_msg/item_completed/UserMessage → UserPromptSubmit (Codex ≥ 0.147)
|
|
1098
|
+
// response_item/function_call → PreToolUse
|
|
1099
|
+
// response_item/function_call_output → PostToolUse / PostToolUseFailure,
|
|
1100
|
+
// decided by the outcome line Codex
|
|
1101
|
+
// prepends to the output (codexCallFailed)
|
|
1102
|
+
// event_msg/token_count → UsageObserved, and riding on it the
|
|
1103
|
+
// session's live context occupancy and
|
|
1104
|
+
// the CLI's own window (#399)
|
|
1105
|
+
// event_msg/task_started (+window) → ModelObserved (context window)
|
|
1106
|
+
// event_msg/task_complete → Stop (the turn finished)
|
|
1107
|
+
// event_msg/turn_aborted → Stop (the turn was interrupted)
|
|
1108
|
+
// turn_context / response_item.model → model snapshot (ModelObserved on change)
|
|
1109
|
+
// turn_context.approval_policy → session snapshot, spread onto every
|
|
1110
|
+
// payload below (#398); no event of its own
|
|
1111
|
+
// There is deliberately no SessionEnd here: Codex writes no session-close
|
|
1112
|
+
// record, so the end of a SESSION is still inferred by sweepStaleSessions.
|
|
1113
|
+
// Nor is there anything that maps to `Notification`, and no synthetic one is
|
|
1114
|
+
// invented: Codex writes no approval record to a rollout at all, so the deck
|
|
1115
|
+
// cannot see a Codex session blocked on a human and does not pretend to — see
|
|
1116
|
+
// codex-approval.ts for the evidence and for what is said instead of guessing.
|
|
1117
|
+
// Events are emitted with source "codex" so pushEvent skips the Claude-only
|
|
1118
|
+
// transcript enrichment (which needs transcript_path / hook events) but still
|
|
1119
|
+
// broadcasts them exactly like a hook event, and persists them when this deck is
|
|
1120
|
+
// the one elected to log this rollout — see writesCodexLog. This path is
|
|
1121
|
+
// entirely additive — the Claude hook flow is untouched.
|
|
1122
|
+
const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
|
|
1123
|
+
const codexSessionModel = new Map(); // sid -> last model string
|
|
1124
|
+
// sid -> the `approval_policy` of the newest `turn_context` seen on this
|
|
1125
|
+
// session. See codexObjToPayload for why this is read and what it is NOT used
|
|
1126
|
+
// for; #398 for why the deck holds it at all.
|
|
1127
|
+
const codexSessionApproval = new Map(); // sid -> last approval_policy string
|
|
1128
|
+
// How long a rollout's tail cursor is kept after it stops showing up in the
|
|
1129
|
+
// listing. The listing covers two day-directories, so anything missing from it
|
|
1130
|
+
// is at least a day old and will never be appended to again.
|
|
1131
|
+
const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
|
|
1132
|
+
let codexScanRunning = false;
|
|
1133
|
+
let codexWatchTimer = null;
|
|
1134
|
+
let codexWorkspace = "";
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Every deck registered right now, as its own discovery record spells it.
|
|
1138
|
+
*
|
|
1139
|
+
* These are the files hook.js enumerates; this is the server reading them for
|
|
1140
|
+
* itself, because the rollout watcher has no hook to do it and still has to know
|
|
1141
|
+
* which other decks are tailing the same file into the same log. Dead pids are
|
|
1142
|
+
* ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
|
|
1143
|
+
* running every 1.5s is no place to be deleting other decks' registrations.
|
|
1144
|
+
*/
|
|
1145
|
+
async function readLiveDecks() {
|
|
1146
|
+
const dir = join(claudeConfigDir(), "agent-dag");
|
|
1147
|
+
let files;
|
|
1148
|
+
try { files = await readdir(dir); } catch { return []; }
|
|
1149
|
+
const decks = [];
|
|
1150
|
+
for (const f of files) {
|
|
1151
|
+
if (!f.endsWith(".json")) continue;
|
|
1152
|
+
try {
|
|
1153
|
+
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
1154
|
+
// Without both numbers there is nothing to group or tie-break on, so the
|
|
1155
|
+
// record cannot take part in an election either way.
|
|
1156
|
+
if (!d || typeof d.pid !== "number" || typeof d.port !== "number") continue;
|
|
1157
|
+
if (!isProcessAlive(d.pid)) continue;
|
|
1158
|
+
decks.push(d);
|
|
1159
|
+
} catch { /* corrupt, or gone between listing and read */ }
|
|
1160
|
+
}
|
|
1161
|
+
return decks;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// List rollout files from the newest 2 day-directories. New sessions always
|
|
1165
|
+
// land in today's dir, so this captures live activity without scanning years
|
|
1166
|
+
// of history every tick.
|
|
1167
|
+
async function listRecentCodexRollouts() {
|
|
1168
|
+
const out = [];
|
|
1169
|
+
let dayDirs = 0;
|
|
1170
|
+
await walkRolloutDays((dir, files) => {
|
|
1171
|
+
for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
|
|
1172
|
+
// Two day-directories deep is the whole point of this listing: it runs every
|
|
1173
|
+
// tick, and anything older than that is a session no process will append to.
|
|
1174
|
+
if (++dayDirs >= 2) return STOP;
|
|
1175
|
+
});
|
|
1176
|
+
return out;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Read the first complete JSON line of a rollout (the session_meta header)
|
|
1180
|
+
// to learn sid + cwd before we start streaming. The header line can be large
|
|
1181
|
+
// (base_instructions text runs tens of KB), so we read in growing chunks until
|
|
1182
|
+
// we hit the first newline rather than guessing a fixed window.
|
|
1183
|
+
async function readCodexHeader(path) {
|
|
1184
|
+
try {
|
|
1185
|
+
const size = (await stat(path)).size;
|
|
1186
|
+
if (size === 0) return null;
|
|
1187
|
+
const CHUNK = 65536;
|
|
1188
|
+
let upto = Math.min(CHUNK, size);
|
|
1189
|
+
let text = "";
|
|
1190
|
+
for (;;) {
|
|
1191
|
+
text = await readByteRange(path, 0, upto);
|
|
1192
|
+
const nl = text.indexOf("\n");
|
|
1193
|
+
if (nl >= 0) {
|
|
1194
|
+
const obj = JSON.parse(text.slice(0, nl));
|
|
1195
|
+
if (obj && obj.type === "session_meta" && obj.payload) {
|
|
1196
|
+
return { sid: obj.payload.id, cwd: typeof obj.payload.cwd === "string" ? obj.payload.cwd : null };
|
|
1197
|
+
}
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
if (upto >= size) return null; // no newline in the whole file yet
|
|
1201
|
+
upto = Math.min(upto + CHUNK, size); // grow and retry
|
|
1202
|
+
if (upto > 4 * 1024 * 1024) return null; // 4MB sanity cap on a single line
|
|
1203
|
+
}
|
|
1204
|
+
} catch {}
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* The human's prompt out of a 0.147-era `item_completed` item.
|
|
1210
|
+
*
|
|
1211
|
+
* `item.content` is an array of parts — every UserMessage observed carries a
|
|
1212
|
+
* single `{ type: "text", text, text_elements }` — so the parts are joined
|
|
1213
|
+
* rather than indexed, and a part with no string `text` contributes nothing
|
|
1214
|
+
* instead of printing "undefined" into the prompt the card shows.
|
|
1215
|
+
*/
|
|
1216
|
+
function codexItemText(item) {
|
|
1217
|
+
const parts = Array.isArray(item && item.content) ? item.content : [];
|
|
1218
|
+
return parts.map(p => (p && typeof p.text === "string" ? p.text : "")).join("");
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Map one parsed rollout object to a synthetic hook payload (or null to skip).
|
|
1222
|
+
// Mutates codexSessionModel and returns { payload, modelEvent } where
|
|
1223
|
+
// modelEvent is an optional ModelObserved to emit first when the model changed.
|
|
1224
|
+
//
|
|
1225
|
+
// Exported for the tests: this is the whole of the Codex translation, and the
|
|
1226
|
+
// lifecycle it produces is worth pinning against the real reducer without
|
|
1227
|
+
// standing up a watcher, a temp home and a 1.5s poll to get at it.
|
|
1228
|
+
export function codexObjToPayload(obj, sid, cwd) {
|
|
1229
|
+
const type = obj && obj.type;
|
|
1230
|
+
const pl = (obj && obj.payload) || {};
|
|
1231
|
+
const model = codexSessionModel.get(sid);
|
|
1232
|
+
// Rides on every payload this function returns, exactly as `model` does and
|
|
1233
|
+
// for the same reason: it is a property of the SESSION rather than of any one
|
|
1234
|
+
// event, the rollout restates it on every turn, and a field spread onto each
|
|
1235
|
+
// payload needs no event of its own and self-heals on the next line the
|
|
1236
|
+
// watcher reads. The reducer stamps it on the root beside `contextWindow`.
|
|
1237
|
+
const approval_policy = codexSessionApproval.get(sid);
|
|
1238
|
+
const base = { session_id: sid, cwd, provider: "codex", approval_policy };
|
|
1239
|
+
|
|
1240
|
+
// Track model from turn_context / response_item before mapping events.
|
|
1241
|
+
if (type === "turn_context") {
|
|
1242
|
+
if (typeof pl.model === "string") codexSessionModel.set(sid, pl.model);
|
|
1243
|
+
// #398: the deck used to read `model` out of this record and drop
|
|
1244
|
+
// everything else, and `approval_policy` was the field that mattered most
|
|
1245
|
+
// among the discarded ones. It is the ONLY recorded fact anywhere in a
|
|
1246
|
+
// rollout that says whether this session is even capable of stopping to ask
|
|
1247
|
+
// a human: at "never" Codex denies an escalation outright rather than
|
|
1248
|
+
// prompting, so a quiet session there is genuinely working, while at
|
|
1249
|
+
// "on-request" / "on-failure" / "untrusted" a quiet one may be parked on a
|
|
1250
|
+
// prompt the deck will never see.
|
|
1251
|
+
//
|
|
1252
|
+
// It is read to SAY that, and deliberately not to infer a block from it —
|
|
1253
|
+
// see codex-approval.ts. Codex persists no approval record of any kind (58
|
|
1254
|
+
// turn_contexts and ~1,100 records across the rollouts on this machine
|
|
1255
|
+
// carry no approval-shaped type; the persist filter keeps every *_end and
|
|
1256
|
+
// drops every request/begin — patch_apply_end with no patch_apply_begin,
|
|
1257
|
+
// web_search_end with no begin, item_completed with no item_started), so a
|
|
1258
|
+
// "waiting" state derived from this plus a pending call would be a guess
|
|
1259
|
+
// wearing the clothes of a measurement.
|
|
1260
|
+
//
|
|
1261
|
+
// Written on the record even when the value is missing or not a string, so
|
|
1262
|
+
// a future Codex that renames or drops the field clears the stale answer
|
|
1263
|
+
// rather than pinning the session to whatever it last said.
|
|
1264
|
+
if (typeof pl.approval_policy === "string" && pl.approval_policy) {
|
|
1265
|
+
codexSessionApproval.set(sid, pl.approval_policy);
|
|
1266
|
+
} else {
|
|
1267
|
+
codexSessionApproval.delete(sid);
|
|
1268
|
+
}
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
if (type === "response_item" && typeof pl.model === "string") {
|
|
1272
|
+
codexSessionModel.set(sid, pl.model);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
if (type === "event_msg") {
|
|
1276
|
+
if (pl.type === "user_message") {
|
|
1277
|
+
const prompt = typeof pl.message === "string" ? pl.message : "";
|
|
1278
|
+
return { ...base, hook_event_name: "UserPromptSubmit", prompt, model };
|
|
1279
|
+
}
|
|
1280
|
+
// Codex 0.147 stopped writing `user_message` and writes the same submission
|
|
1281
|
+
// as an `item_completed` carrying a `UserMessage` item instead. The two are
|
|
1282
|
+
// mutually exclusive per CLI version — across the rollouts sampled here the
|
|
1283
|
+
// 0.144 files have `user_message` and no `item_completed` at all, and the
|
|
1284
|
+
// 0.147 files have exactly one `UserMessage` item per turn and no
|
|
1285
|
+
// `user_message` — so handling both names emits one prompt per turn either
|
|
1286
|
+
// way rather than two on either version. The item is the human's typed text
|
|
1287
|
+
// only: the AGENTS.md preamble Codex prepends is written as a bare
|
|
1288
|
+
// `response_item` with role "user" and never gets an item of its own.
|
|
1289
|
+
//
|
|
1290
|
+
// This matters far beyond the prompt text. `UserPromptSubmit` is what puts
|
|
1291
|
+
// a settled root back to `active` (reducer.ts), so on 0.147 it is the ONLY
|
|
1292
|
+
// thing that reopens a session for its second turn — without it the `Stop`
|
|
1293
|
+
// below would trade "live forever" for "done forever", which is not better.
|
|
1294
|
+
if (pl.type === "item_completed" && pl.item && pl.item.type === "UserMessage") {
|
|
1295
|
+
return { ...base, hook_event_name: "UserPromptSubmit", prompt: codexItemText(pl.item), model };
|
|
1296
|
+
}
|
|
1297
|
+
// Codex states THREE numbers on every `token_count` and this branch used to
|
|
1298
|
+
// take one of them (#399). The other two are what the context donut is drawn
|
|
1299
|
+
// from, so the deck rendered no context readout at all for the only provider
|
|
1300
|
+
// that reports its window exactly.
|
|
1301
|
+
//
|
|
1302
|
+
// Measured across every rollout under this machine's CODEX_HOME — 178
|
|
1303
|
+
// `token_count` records, 164 on Codex 0.144.5 and 14 on 0.147.0 — all 178
|
|
1304
|
+
// carry `info.last_token_usage`, `info.total_token_usage` and
|
|
1305
|
+
// `info.model_context_window`. Nothing here is a new read: the line is
|
|
1306
|
+
// already parsed and the object already destructured.
|
|
1307
|
+
//
|
|
1308
|
+
// total_token_usage cumulative SPEND for the session. Every request's
|
|
1309
|
+
// prompt summed, so it counts the cached prefix again
|
|
1310
|
+
// on every turn and passes the context window many
|
|
1311
|
+
// times over inside one session (5,238,700 against a
|
|
1312
|
+
// 258,400 window in the longest file here). Correct
|
|
1313
|
+
// for cost, meaningless as an occupancy figure.
|
|
1314
|
+
//
|
|
1315
|
+
// last_token_usage the MOST RECENT request: `input_tokens` is the whole
|
|
1316
|
+
// conversation Codex sent (it already contains the
|
|
1317
|
+
// cached prefix — see billedInputTokens), plus that
|
|
1318
|
+
// request's completion. `total_tokens` is exactly
|
|
1319
|
+
// input + output on 177 of the 178 records.
|
|
1320
|
+
//
|
|
1321
|
+
// model_context_window the CLI's own ceiling, 258,400 for gpt-5.6 against
|
|
1322
|
+
// the 1,050,000 the static table guesses.
|
|
1323
|
+
//
|
|
1324
|
+
// WHY `last_token_usage.total_tokens` AND NOT `input_tokens`. The prompt-only
|
|
1325
|
+
// figure is the closer analogue of the Claude side, which sums the last usage
|
|
1326
|
+
// block's input + cache_read + cache_creation and leaves the completion out.
|
|
1327
|
+
// The difference is one response — 20 to 2,288 tokens in this sample, under
|
|
1328
|
+
// 1% of the window — and `total_tokens` wins on the case where they diverge
|
|
1329
|
+
// for real: on `thread_rolled_back` (the user rewinding the conversation)
|
|
1330
|
+
// Codex writes a `token_count` whose per-request components are all zero and
|
|
1331
|
+
// whose `last_token_usage.total_tokens` is the RECOMPUTED context size —
|
|
1332
|
+
// 47,355, down from 58,516 — while `total_token_usage` does not move at all,
|
|
1333
|
+
// because no request was made. Codex is using that field as "tokens in the
|
|
1334
|
+
// window", and reading `input_tokens` there would collapse the donut to 0%
|
|
1335
|
+
// at precisely the moment the number changed most.
|
|
1336
|
+
//
|
|
1337
|
+
// The window rides along too. `task_started` below is the only other carrier
|
|
1338
|
+
// and it fires once per turn, so a deck that attached mid-turn — the ordinary
|
|
1339
|
+
// case, since the watcher skips a pre-existing session's history at startup —
|
|
1340
|
+
// had to wait for the next turn before the donut could be scaled against
|
|
1341
|
+
// anything but the wrong static default.
|
|
1342
|
+
if (pl.type === "token_count" && pl.info) {
|
|
1343
|
+
const info = pl.info;
|
|
1344
|
+
const last = info.last_token_usage;
|
|
1345
|
+
const contextTokens = last && typeof last.total_tokens === "number" ? last.total_tokens : undefined;
|
|
1346
|
+
const window = typeof info.model_context_window === "number" ? info.model_context_window : undefined;
|
|
1347
|
+
// A record that states none of the three says nothing, and emitting an
|
|
1348
|
+
// event for it would put an empty envelope in the ring buffer and in the
|
|
1349
|
+
// persisted log for every reader to skip forever.
|
|
1350
|
+
if (!info.total_token_usage && contextTokens === undefined && window === undefined) return null;
|
|
1351
|
+
return {
|
|
1352
|
+
...base,
|
|
1353
|
+
hook_event_name: "UsageObserved",
|
|
1354
|
+
usage: info.total_token_usage,
|
|
1355
|
+
model,
|
|
1356
|
+
model_context_window: window,
|
|
1357
|
+
context_tokens: contextTokens,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
|
|
1361
|
+
return { ...base, hook_event_name: "ModelObserved", model, model_context_window: pl.model_context_window };
|
|
1362
|
+
}
|
|
1363
|
+
// The end of a turn, which is the only end Codex ever announces. Both names
|
|
1364
|
+
// are one outcome as far as the deck is concerned — the turn is over and
|
|
1365
|
+
// nothing is running — so both settle the root the way Claude's own Stop
|
|
1366
|
+
// hook does. They are also exhaustive: across the rollouts sampled here
|
|
1367
|
+
// every `task_started` is answered by exactly one of the two (54 completes
|
|
1368
|
+
// + 1 abort for 55 starts), so no turn is left open by this mapping and
|
|
1369
|
+
// none is closed twice. `turn_aborted` is the Esc key, and it is the case
|
|
1370
|
+
// that matters most on the deck: pressing Esc is precisely when the user is
|
|
1371
|
+
// watching to confirm the thing stopped.
|
|
1372
|
+
//
|
|
1373
|
+
// Per TURN, not per session, and that is correct: Codex writes no
|
|
1374
|
+
// session-close record at all — a rollout simply stops growing when the
|
|
1375
|
+
// terminal goes away — so `sweepStaleSessions` remains the only thing that
|
|
1376
|
+
// ends a Codex *session*, and it must. What changes is that it now only
|
|
1377
|
+
// ever sees the sessions it was written for: the ones that died without
|
|
1378
|
+
// finishing. A turn that really ended is settled here, at the moment it
|
|
1379
|
+
// ended, with no `reaped` flag, because it was a finish and not a guess.
|
|
1380
|
+
if (pl.type === "task_complete" || pl.type === "turn_aborted") {
|
|
1381
|
+
return { ...base, hook_event_name: "Stop", model };
|
|
1382
|
+
}
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
if (type === "response_item") {
|
|
1386
|
+
if (pl.type === "function_call") {
|
|
1387
|
+
let input = pl.arguments;
|
|
1388
|
+
try { input = JSON.parse(pl.arguments); } catch {}
|
|
1389
|
+
return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: input, tool_use_id: pl.call_id, model };
|
|
1390
|
+
}
|
|
1391
|
+
if (pl.type === "custom_tool_call") {
|
|
1392
|
+
return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: codexCustomToolInput(pl.name, pl.input), tool_use_id: pl.call_id, model };
|
|
1393
|
+
}
|
|
1394
|
+
// #397: the outcome, not just the fact that an outcome arrived. This used
|
|
1395
|
+
// to hardcode "PostToolUse" for both output types, and the reducer derives
|
|
1396
|
+
// `ok` from the event NAME (`tc.ok = name === "PostToolUse"`) — so `ok` was
|
|
1397
|
+
// structurally incapable of being false on the Codex path and a command
|
|
1398
|
+
// that exited non-zero drew exactly like one that succeeded. Every surface
|
|
1399
|
+
// that reads the flag inherited the lie: the burst dot, the tool row, the
|
|
1400
|
+
// ToolModal styling, the detail-panel error count, and the session
|
|
1401
|
+
// summary's "Errors" stat, which was therefore pinned at 0 for the life of
|
|
1402
|
+
// a Codex session. `PostToolUseFailure` is the name the reducer already
|
|
1403
|
+
// understands (it sets `ok = false` and writes an `errorPreview` from the
|
|
1404
|
+
// response); nothing but this mapper was missing.
|
|
1405
|
+
if (pl.type === "function_call_output" || pl.type === "custom_tool_call_output") {
|
|
1406
|
+
const tool_response = pl.output != null ? parseCodexOutput(pl.output) : undefined;
|
|
1407
|
+
const name = codexCallFailed(pl.output) ? "PostToolUseFailure" : "PostToolUse";
|
|
1408
|
+
return { ...base, hook_event_name: name, tool_use_id: pl.call_id, tool_response, model };
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Wrap a `custom_tool_call`'s raw input under the key that describes what it
|
|
1416
|
+
* actually is.
|
|
1417
|
+
*
|
|
1418
|
+
* WHY THIS IS NOT JUST `{ patch: input }` ANY MORE (#417). Codex's
|
|
1419
|
+
* `custom_tool_call` container carries a bare string and nothing that says what
|
|
1420
|
+
* kind of string it is — the tool's NAME is the only discriminator. When this
|
|
1421
|
+
* branch was written the container had exactly one inhabitant, `apply_patch`,
|
|
1422
|
+
* so it hardcoded `patch` and was right. It is no longer the only inhabitant,
|
|
1423
|
+
* and on 0.147 it is not even the common one:
|
|
1424
|
+
*
|
|
1425
|
+
* CLI custom_tool_call name count
|
|
1426
|
+
* 0.144.5 exec 77
|
|
1427
|
+
* 0.144.5 apply_patch 2
|
|
1428
|
+
* 0.147.0 exec 6
|
|
1429
|
+
*
|
|
1430
|
+
* Eighty-three shell scripts were therefore filed on the deck as patches. The
|
|
1431
|
+
* client's `commandStringOf` reads `cmd` / `command` / `script` and never
|
|
1432
|
+
* `patch`, so no Codex call could show what it ran; `extractFilePath` mean-
|
|
1433
|
+
* while reads `patch` and tried to find a `*** Update File:` header in a
|
|
1434
|
+
* JavaScript program. Keying off the name fixes both directions at once.
|
|
1435
|
+
*
|
|
1436
|
+
* WHY `exec` GETS `script` AND NOT `command`. Because it is not a command. The
|
|
1437
|
+
* `exec` tool takes a small JavaScript program that calls into a `tools.*` API
|
|
1438
|
+
* — 83 of 83 inputs on this machine start with `const `, none parses as JSON,
|
|
1439
|
+
* all are multi-line — and Codex's own result wrapper calls it one, prefixing
|
|
1440
|
+
* the output with "Script completed" / "Script failed" rather than an exit code
|
|
1441
|
+
* (the line #397 reads). Filing a program under `command` would make the deck
|
|
1442
|
+
* draw the first token of the program as the command that ran, which is the
|
|
1443
|
+
* word `const` on every Codex call in the session. `script` is both true and
|
|
1444
|
+
* already understood by the client, which digs the real command out of the
|
|
1445
|
+
* program from there.
|
|
1446
|
+
*
|
|
1447
|
+
* WHY THE FALLBACK IS `input` AND NOT A GUESS. A name this function has never
|
|
1448
|
+
* heard of is a string whose meaning is unknown, and the one thing worse than
|
|
1449
|
+
* showing it under a neutral key is showing it under a confident wrong one —
|
|
1450
|
+
* that is the whole of this bug, repeated. `input` claims nothing; the tool's
|
|
1451
|
+
* own name still reaches the bubble, so an unrecognised Codex tool degrades to
|
|
1452
|
+
* "a tool I cannot read the arguments of" instead of "a patch".
|
|
1453
|
+
*/
|
|
1454
|
+
const CODEX_CUSTOM_TOOL_INPUT_KEY = {
|
|
1455
|
+
// A `*** Begin Patch … *** End Patch` document (2/2 observed, both 0.144.5).
|
|
1456
|
+
// Unchanged, and it has to stay unchanged: the client's extractFilePath()
|
|
1457
|
+
// pulls the edited file's path out of `input.patch` for the sub-bubble.
|
|
1458
|
+
apply_patch: "patch",
|
|
1459
|
+
// A JavaScript program. See above.
|
|
1460
|
+
exec: "script",
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
function codexCustomToolInput(name, input) {
|
|
1464
|
+
const key = CODEX_CUSTOM_TOOL_INPUT_KEY[name] ?? "input";
|
|
1465
|
+
return { [key]: input };
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The text parts of a Codex tool result, in the order Codex wrote them.
|
|
1470
|
+
*
|
|
1471
|
+
* Codex writes the result in two different containers and the deck sees both,
|
|
1472
|
+
* so this is where the difference stops. Across the rollouts sampled here:
|
|
1473
|
+
* `custom_tool_call_output.output` is an ARRAY of `{ type: "input_text", text }`
|
|
1474
|
+
* parts (85/85, on 0.144 and 0.147 alike), and `function_call_output.output` is
|
|
1475
|
+
* a bare string (32/32). The `{ output, metadata }` envelope `parseCodexOutput`
|
|
1476
|
+
* unwraps was written by neither, but it is cheap to keep tolerating and the
|
|
1477
|
+
* unwrapping already lives there, so the string case is routed through it
|
|
1478
|
+
* rather than duplicating the guess.
|
|
1479
|
+
*/
|
|
1480
|
+
function codexOutputParts(output) {
|
|
1481
|
+
if (output == null) return [];
|
|
1482
|
+
if (Array.isArray(output)) {
|
|
1483
|
+
return output.map(p => (p && typeof p.text === "string" ? p.text : ""));
|
|
1484
|
+
}
|
|
1485
|
+
const unwrapped = parseCodexOutput(output);
|
|
1486
|
+
return typeof unwrapped === "string" ? [unwrapped] : [];
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* Did this Codex tool call actually fail?
|
|
1491
|
+
*
|
|
1492
|
+
* Codex prepends its own wrapper line to the tool's output and that line — not
|
|
1493
|
+
* any structured field — is where the outcome lives. Verified against every
|
|
1494
|
+
* tool result in this machine's CODEX_HOME:
|
|
1495
|
+
*
|
|
1496
|
+
* 0.144.5 exec "Script completed" 75 "Script failed" 2
|
|
1497
|
+
* 0.147.0 exec "Script completed" 6 (no failure observed)
|
|
1498
|
+
* 0.144.5 apply_patch "Exit code: 0" 2
|
|
1499
|
+
* 0.144.5 exec_command / run — bare string, no wrapper line at all 32
|
|
1500
|
+
*
|
|
1501
|
+
* The two CLI versions spell it IDENTICALLY, which is why one rule covers both
|
|
1502
|
+
* and why this needs no version sniffing: 0.147 renamed the prompt event (see
|
|
1503
|
+
* the `item_completed` branch above) but left the exec wrapper alone.
|
|
1504
|
+
*
|
|
1505
|
+
* Only the FIRST part's FIRST line is read, and that precision is load-bearing
|
|
1506
|
+
* rather than tidiness. The wrapper line is at part index 0 in 85 of 85 results
|
|
1507
|
+
* that have one; the later parts are the command's own stdout, and the command
|
|
1508
|
+
* prints whatever it likes there. On this machine two exec results contain a
|
|
1509
|
+
* line reading "Script error:" in part 1 — output from a script that ran fine
|
|
1510
|
+
* under a wrapper that says "Script completed" — so a rule that scanned every
|
|
1511
|
+
* part would paint two successful calls red. `\r` is stripped because the same
|
|
1512
|
+
* wrapper is written by Codex on Windows.
|
|
1513
|
+
*
|
|
1514
|
+
* Silence means success, deliberately. A result with no wrapper line — every
|
|
1515
|
+
* `function_call_output` on 0.144, and whatever container a future Codex
|
|
1516
|
+
* invents — keeps today's behaviour of mapping to `PostToolUse`. Reporting an
|
|
1517
|
+
* unknown outcome as a failure would trade one wrong colour for another, and
|
|
1518
|
+
* this direction is the recoverable one: a missed failure is a call that draws
|
|
1519
|
+
* as it always has, while a false failure puts a red dot and an "Errors" count
|
|
1520
|
+
* on a session that did nothing wrong.
|
|
1521
|
+
*/
|
|
1522
|
+
function codexCallFailed(output) {
|
|
1523
|
+
const first = codexOutputParts(output)[0];
|
|
1524
|
+
if (typeof first !== "string") return false;
|
|
1525
|
+
const head = first.split("\n")[0].replace(/\r$/, "");
|
|
1526
|
+
if (/^Script failed\b/.test(head)) return true;
|
|
1527
|
+
// apply_patch reports itself with an exit code instead of a word. Anything
|
|
1528
|
+
// non-zero is a patch that did not apply.
|
|
1529
|
+
const exit = /^Exit code:\s*(\d+)/.exec(head);
|
|
1530
|
+
if (exit) return Number(exit[1]) !== 0;
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function parseCodexOutput(raw) {
|
|
1535
|
+
if (typeof raw !== "string") return raw;
|
|
1536
|
+
try {
|
|
1537
|
+
const o = JSON.parse(raw);
|
|
1538
|
+
return (o && typeof o.output === "string") ? o.output : raw;
|
|
1539
|
+
} catch {
|
|
1540
|
+
return raw;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// `persist` is false when another deck tailing this same rollout was elected to
|
|
1545
|
+
// write it to the log they share. The event is still buffered and broadcast —
|
|
1546
|
+
// every deck watching the session draws it — it is only the second copy on disk
|
|
1547
|
+
// that is dropped. See writesCodexLog in log-writer.mjs.
|
|
1548
|
+
function emitCodexEvent(payload, persist) {
|
|
1549
|
+
pushEvent(payload, "codex", { persist });
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Emit the SessionStart root exactly once per file, lazily — only when the
|
|
1553
|
+
// session actually produces an event. This keeps long-dead sessions that were
|
|
1554
|
+
// merely on disk at startup from cluttering the canvas with empty roots.
|
|
1555
|
+
function ensureCodexRoot(state, persist) {
|
|
1556
|
+
if (state.rootEmitted) return;
|
|
1557
|
+
state.rootEmitted = true;
|
|
1558
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async function codexScanOnce(firstRun) {
|
|
1562
|
+
if (codexScanRunning) return;
|
|
1563
|
+
codexScanRunning = true;
|
|
1564
|
+
try {
|
|
1565
|
+
const now = Date.now();
|
|
1566
|
+
// Read at most once per scan, and only from the first rollout that actually
|
|
1567
|
+
// has new bytes: most ticks read nothing and must not pay a directory
|
|
1568
|
+
// listing for the answer to a question nothing is asking.
|
|
1569
|
+
let decksRead = null;
|
|
1570
|
+
const liveDecks = () => (decksRead ??= readLiveDecks());
|
|
1571
|
+
const files = await listRecentCodexRollouts();
|
|
1572
|
+
for (const path of files) {
|
|
1573
|
+
let st;
|
|
1574
|
+
try { st = await stat(path); } catch { continue; }
|
|
1575
|
+
let state = codexFileState.get(path);
|
|
1576
|
+
if (state) state.seenAt = now;
|
|
1577
|
+
|
|
1578
|
+
if (!state) {
|
|
1579
|
+
// New file — read the header for sid + cwd, then decide whether to
|
|
1580
|
+
// capture it. Skip files outside our workspace.
|
|
1581
|
+
const header = await readCodexHeader(path);
|
|
1582
|
+
if (!header || !header.sid) continue; // not ready yet — retry next tick
|
|
1583
|
+
if (!codexCwdInWorkspace(header.cwd, codexWorkspace)) {
|
|
1584
|
+
codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false, seenAt: now };
|
|
1588
|
+
codexFileState.set(path, state);
|
|
1589
|
+
if (firstRun) {
|
|
1590
|
+
// On startup, skip a pre-existing session's history entirely — no
|
|
1591
|
+
// root, no replay. Only future appends (a live session that keeps
|
|
1592
|
+
// going) will lazily create the root via ensureCodexRoot.
|
|
1593
|
+
state.offset = st.size;
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
if (state.skip) { state.offset = st.size; continue; }
|
|
1599
|
+
if (st.size <= state.offset) continue;
|
|
1600
|
+
|
|
1601
|
+
const text = await readByteRange(path, state.offset, st.size);
|
|
1602
|
+
const lastNl = text.lastIndexOf("\n");
|
|
1603
|
+
if (lastNl < 0) continue; // no complete line yet
|
|
1604
|
+
const consume = text.slice(0, lastNl);
|
|
1605
|
+
state.offset += Buffer.byteLength(consume, "utf8") + 1; // +1 for the \n
|
|
1606
|
+
|
|
1607
|
+
// Decided per file rather than per line: which decks are up, and which of
|
|
1608
|
+
// them tail this rollout, cannot change inside one batch of appended
|
|
1609
|
+
// lines, and the answer must be the same for every event in it — a root
|
|
1610
|
+
// written by one deck and its tool calls by another is worse than either.
|
|
1611
|
+
const persist = !persistPath
|
|
1612
|
+
|| writesCodexLog({ decks: await liveDecks(), pid: process.pid, cwd: state.cwd });
|
|
1613
|
+
|
|
1614
|
+
for (const line of consume.split("\n")) {
|
|
1615
|
+
if (!line) continue;
|
|
1616
|
+
let obj;
|
|
1617
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
1618
|
+
const prevModel = codexSessionModel.get(state.sid);
|
|
1619
|
+
const payload = codexObjToPayload(obj, state.sid, state.cwd);
|
|
1620
|
+
// If the model changed (turn_context/response_item), surface it.
|
|
1621
|
+
const nowModel = codexSessionModel.get(state.sid);
|
|
1622
|
+
if (nowModel && nowModel !== prevModel) {
|
|
1623
|
+
ensureCodexRoot(state, persist);
|
|
1624
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel }, persist);
|
|
1625
|
+
}
|
|
1626
|
+
if (payload) {
|
|
1627
|
+
ensureCodexRoot(state, persist);
|
|
1628
|
+
emitCodexEvent(payload, persist);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Once per batch of appended lines rather than once per line — the scan
|
|
1633
|
+
// throttles itself per session, but the cheapest call is the one that is
|
|
1634
|
+
// never made, and a batch can be hundreds of lines. Gated on the root
|
|
1635
|
+
// existing so a session the deck has decided not to draw does not cost a
|
|
1636
|
+
// directory walk, and repeated rather than done once at root creation so
|
|
1637
|
+
// an AGENTS.md written after the session started is still found (#399).
|
|
1638
|
+
//
|
|
1639
|
+
// Carries the same `persist` verdict as every emit in the loop above, and
|
|
1640
|
+
// for the same reason: this is one more event about this batch of appended
|
|
1641
|
+
// lines, and a batch whose roots and tool calls went to one deck's log
|
|
1642
|
+
// while its memory list went to every deck's is the split the election
|
|
1643
|
+
// exists to prevent (#447).
|
|
1644
|
+
if (state.rootEmitted) maybeResolveCodexMemory(state.sid, state.cwd, persist);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Rollout files fall out of the newest-2-days listing and never come back,
|
|
1648
|
+
// but their tail cursors used to live as long as the process did. Expire by
|
|
1649
|
+
// "not seen for a while" rather than "absent from this listing": a single
|
|
1650
|
+
// unreadable directory mid-scan would otherwise drop a live file's cursor,
|
|
1651
|
+
// and re-adding it at offset 0 replays that entire rollout as fresh events.
|
|
1652
|
+
for (const [p, s] of codexFileState) {
|
|
1653
|
+
if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
|
|
1654
|
+
}
|
|
1655
|
+
} catch {
|
|
1656
|
+
/* swallow — watcher must never crash the server */
|
|
1657
|
+
} finally {
|
|
1658
|
+
codexScanRunning = false;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
export function startCodexWatcher(workspace) {
|
|
1663
|
+
codexWorkspace = workspace ?? "";
|
|
1664
|
+
// Deliberately not gated on CODEX_SESSIONS_DIR existing. `codex login`
|
|
1665
|
+
// creates ~/.codex, but sessions/ only appears when the first session
|
|
1666
|
+
// starts — and rollout tailing is the only Codex capture path there is, so
|
|
1667
|
+
// bailing out here meant a fresh install had to restart the deck before any
|
|
1668
|
+
// Codex agent ever showed up, while the banner claimed to be watching.
|
|
1669
|
+
// listRecentCodexRollouts returns [] while the directory is missing, so the
|
|
1670
|
+
// poll below is a cheap no-op that doubles as the existence re-check. A
|
|
1671
|
+
// filesystem watch is no help: fs.watch on a missing path throws, and
|
|
1672
|
+
// watching the parent recursively is macOS/Windows-only.
|
|
1673
|
+
//
|
|
1674
|
+
// Initial catalog: create roots for in-progress sessions, skip their
|
|
1675
|
+
// history, then poll for new lines.
|
|
1676
|
+
codexScanOnce(true).catch(() => {});
|
|
1677
|
+
codexWatchTimer = setInterval(() => { codexScanOnce(false).catch(() => {}); }, 1500);
|
|
1678
|
+
if (codexWatchTimer.unref) codexWatchTimer.unref();
|
|
1679
|
+
return codexWatchTimer;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Envelopes newer than `seq` from the ring buffer, oldest first. */
|
|
1683
|
+
export function eventsSince(seq) {
|
|
1684
|
+
const after = Number(seq) || 0;
|
|
1685
|
+
return events.filter(e => e.seq > after);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// ─── Per-session cache expiry ────────────────────────────────────────────
|
|
1689
|
+
// Every enrichment cache above is keyed by session id and nothing ever
|
|
1690
|
+
// removed an entry: a deck left up for weeks — the 24/7 use this thing is
|
|
1691
|
+
// built for — kept a model string, a subagent signature and four read-throttle
|
|
1692
|
+
// stamps for every session it had ever seen, plus the Codex rollout path and
|
|
1693
|
+
// model of each. SessionEnd is not a usable eviction signal (a killed CLI
|
|
1694
|
+
// never sends one, and Codex has no such hook at all), so entries expire by
|
|
1695
|
+
// least-recent use against a cap instead — the same shape pruneTranscriptScans
|
|
1696
|
+
// already uses for its per-path state. The cap sits far above any plausible
|
|
1697
|
+
// number of concurrent sessions, so a live session is never evicted; and an
|
|
1698
|
+
// evicted one that speaks again simply re-reads its transcript.
|
|
1699
|
+
const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
|
|
1700
|
+
const MAX_TRACKED_SESSIONS = 256;
|
|
1701
|
+
|
|
1702
|
+
function forgetSession(sid) {
|
|
1703
|
+
modelBySession.delete(sid);
|
|
1704
|
+
// The two the session-naming work added (#520/#522) and did not list here.
|
|
1705
|
+
// Both are keyed by session id and nothing else ever removed an entry, which
|
|
1706
|
+
// is the exact leak the comment above says this mechanism exists to end —
|
|
1707
|
+
// every sibling cache is capped at MAX_TRACKED_SESSIONS and these two were
|
|
1708
|
+
// not. The functional half is worse than the leak: nameBySession gates the
|
|
1709
|
+
// SessionNamed emit on "has this changed", so a live session evicted past the
|
|
1710
|
+
// cap and then heard from again re-emits its model (modelBySession was
|
|
1711
|
+
// cleared) and never re-emits its name. A tab that connects after the event
|
|
1712
|
+
// ring has rolled past the original SessionNamed shows that session unnamed
|
|
1713
|
+
// for the rest of its life.
|
|
1714
|
+
nameBySession.delete(sid);
|
|
1715
|
+
lastNameReadAt.delete(sid);
|
|
1716
|
+
modelLastReadAt.delete(sid);
|
|
1717
|
+
lastUsageReadAt.delete(sid);
|
|
1718
|
+
lastContextReadAt.delete(sid);
|
|
1719
|
+
codexRolloutPathBySid.delete(sid);
|
|
1720
|
+
lastCodexUsageReadAt.delete(sid);
|
|
1721
|
+
lastCodexMemoryReadAt.delete(sid);
|
|
1722
|
+
codexSessionModel.delete(sid);
|
|
1723
|
+
codexSessionApproval.delete(sid);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function touchSession(sid) {
|
|
1727
|
+
if (!sid || typeof sid !== "string") return;
|
|
1728
|
+
// Re-insert so the Map's own insertion order *is* the LRU order and eviction
|
|
1729
|
+
// below is one key read rather than a scan of every session ever seen.
|
|
1730
|
+
sessionTouchedAt.delete(sid);
|
|
1731
|
+
sessionTouchedAt.set(sid, Date.now());
|
|
1732
|
+
while (sessionTouchedAt.size > MAX_TRACKED_SESSIONS) {
|
|
1733
|
+
const oldest = sessionTouchedAt.keys().next().value;
|
|
1734
|
+
sessionTouchedAt.delete(oldest);
|
|
1735
|
+
forgetSession(oldest);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// ─── SSE backpressure ────────────────────────────────────────────────────
|
|
1740
|
+
// A client that stops reading without closing its socket — a frozen tab, a
|
|
1741
|
+
// suspended machine, a stalled `ssh -L` tunnel — never fires 'close' and never
|
|
1742
|
+
// makes write() throw, so the old `try { res.write(line) } catch {}` had no
|
|
1743
|
+
// way to notice it. On loopback nothing times the connection out either, so
|
|
1744
|
+
// every event (tool responses run to megabytes) queued in that socket's write
|
|
1745
|
+
// buffer for as long as the process lived.
|
|
1746
|
+
//
|
|
1747
|
+
// We drop the client rather than the events. EventSource reconnects after the
|
|
1748
|
+
// `retry: 1500` we send on connect and resumes from Last-Event-ID, so the ring
|
|
1749
|
+
// buffer replays whatever it missed. Dropping individual events instead would
|
|
1750
|
+
// leave a hole the resume path cannot even see, the client's last id having
|
|
1751
|
+
// moved past it.
|
|
1752
|
+
//
|
|
1753
|
+
// The ceiling has to clear the largest SINGLE frame the deck can emit, because
|
|
1754
|
+
// one write() of such a frame puts the whole of it in the queue with nothing
|
|
1755
|
+
// having had the chance to drain any of it — a client reading at full speed
|
|
1756
|
+
// looks, for that instant, exactly like a frozen tab. #588 was exactly that
|
|
1757
|
+
// failure: queuedBytes below doubled every reading, so the real ceiling was
|
|
1758
|
+
// 4 MiB and one 4 MiB tool response hung up on every subscribed tab at once.
|
|
1759
|
+
//
|
|
1760
|
+
// So the number is checked against what `POST /api/event` admits rather than
|
|
1761
|
+
// left to feel. handleEventIngest caps a body at 5,000,000 CHARACTERS. The
|
|
1762
|
+
// event is re-serialized before it goes out, and re-serializing a value that
|
|
1763
|
+
// came from JSON.parse of an N-character document cannot exceed N characters —
|
|
1764
|
+
// every escape the output needs was already paid for in the input, and \uXXXX
|
|
1765
|
+
// input comes back shorter — so one frame is at most 5,000,000 characters, plus
|
|
1766
|
+
// this deck's envelope, measured at 127, plus the id/event/data framing. 8 MiB
|
|
1767
|
+
// clears that by a little over 1.6x, and is the number this constant has always
|
|
1768
|
+
// named; what #588 changed is that it now means it.
|
|
1769
|
+
//
|
|
1770
|
+
// Characters, not bytes, which is the one misleading thing left in the name.
|
|
1771
|
+
// writeSse and writeResume write STRINGS, and a Writable with decodeStrings
|
|
1772
|
+
// false — which both an OutgoingMessage and the net.Socket under it are — adds
|
|
1773
|
+
// `chunk.length`, i.e. UTF-16 units, to its queue. Measured on Node 22.14: a
|
|
1774
|
+
// 4,800,000-character Read of CJK text is 14,400,184 bytes on the wire and
|
|
1775
|
+
// `res.writableLength` reports 4,800,311. That is why comparing this against a
|
|
1776
|
+
// character-denominated ingest limit is the right comparison and comparing it
|
|
1777
|
+
// against a byte count would not be — and it is worth stating plainly, because
|
|
1778
|
+
// assuming a unit for writableLength instead of measuring it is the whole
|
|
1779
|
+
// shape of the bug this comment exists to explain. The memory behind a full
|
|
1780
|
+
// buffer is larger than the number says, up to two bytes per unit while it is
|
|
1781
|
+
// held as a string; that is not what the cap is for, which is noticing a client
|
|
1782
|
+
// that has stopped reading at all.
|
|
1783
|
+
//
|
|
1784
|
+
// Exported, with queuedBytes, so the arithmetic can be asserted directly. #588
|
|
1785
|
+
// survived because it could only be observed through a live socket, where the
|
|
1786
|
+
// existing tests' tolerances were wider than the error.
|
|
1787
|
+
export const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* What this response has accepted and not yet handed to the kernel, in the
|
|
1791
|
+
* units writableLength reports it in — see MAX_CLIENT_BUFFER_BYTES above, which
|
|
1792
|
+
* is the number this is compared against.
|
|
1793
|
+
*
|
|
1794
|
+
* NOT the sum of the two writableLengths, which is what #588 was: Node's
|
|
1795
|
+
* `OutgoingMessage.writableLength` getter is `outputSize + this[kChunkedLength]
|
|
1796
|
+
* + (socket ? socket.writableLength : 0)`, so the socket's queue is already
|
|
1797
|
+
* inside it. For an SSE response, whose outputSize is zero from the moment the
|
|
1798
|
+
* headers flush, the two readings are the same number exactly — measured on
|
|
1799
|
+
* Node 22.14 against a paused reader, `res.writableLength=4194615
|
|
1800
|
+
* socket.writableLength=4194615` — so adding them reported exactly twice the
|
|
1801
|
+
* real backlog and made an 8 MiB constant behave as a 4 MiB one.
|
|
1802
|
+
*
|
|
1803
|
+
* Why max and not simply `res.writableLength`, which is today's whole answer.
|
|
1804
|
+
* That composition is a Node implementation detail and it has moved before, so
|
|
1805
|
+
* the expression is chosen to survive it moving again. Read the two as an
|
|
1806
|
+
* overlapping pair and take the larger:
|
|
1807
|
+
* - composed as it is today, `own` already contains `sock`, so `own >= sock`
|
|
1808
|
+
* and max is `own` — the exact total;
|
|
1809
|
+
* - were the getter to stop including the socket term, `own` for a flushed
|
|
1810
|
+
* SSE response is zero and max is `sock` — again the exact total;
|
|
1811
|
+
* - with the socket detached (`res.socket` null, which happens between the
|
|
1812
|
+
* response ending and the handle being released) max is `own`, the only
|
|
1813
|
+
* reading there is.
|
|
1814
|
+
* Every case is right, and the failure mode if some future composition makes
|
|
1815
|
+
* both terms non-zero and disjoint is under-counting by at most 2x — a client
|
|
1816
|
+
* held a little longer than intended, which is the harmless direction. Summing
|
|
1817
|
+
* fails the other way, and dropping readers that are not behind is the bug.
|
|
1818
|
+
*/
|
|
1819
|
+
export function queuedBytes(res) {
|
|
1820
|
+
const own = typeof res.writableLength === "number" ? res.writableLength : 0;
|
|
1821
|
+
const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
|
|
1822
|
+
return Math.max(own, sock);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/** Hang up on a client we have decided not to keep. `delete` on a response
|
|
1826
|
+
* that never made it into the set — the resume path below hangs up on clients
|
|
1827
|
+
* before they are subscribed — is a harmless no-op. */
|
|
1828
|
+
function dropSse(res) {
|
|
1829
|
+
sseClients.delete(res);
|
|
1830
|
+
// Destroying the socket is what makes the request emit 'close', which is
|
|
1831
|
+
// where the ping interval is cleared.
|
|
1832
|
+
try { res.destroy(); } catch {}
|
|
1833
|
+
try { res.socket?.destroy(); } catch {}
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/** Write one SSE frame, hanging up on a client too far behind to keep. */
|
|
1837
|
+
function writeSse(res, frame) {
|
|
1838
|
+
try {
|
|
1839
|
+
res.write(frame);
|
|
1840
|
+
if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
|
|
1841
|
+
} catch { /* already dead — drop it below */ }
|
|
1842
|
+
dropSse(res);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// How long a resuming client is given to accept the bytes already queued for
|
|
1846
|
+
// it before the deck concludes it is not reading at all. Generous on purpose:
|
|
1847
|
+
// what it has to work through is a full MAX_CLIENT_BUFFER_BYTES, the link may
|
|
1848
|
+
// be an `ssh -L` tunnel rather than loopback, and dropping a client that is
|
|
1849
|
+
// merely slow costs it the whole replay. A tab that is genuinely frozen will
|
|
1850
|
+
// not accept a byte in any budget, so the only thing a long one buys it is a
|
|
1851
|
+
// few more seconds of holding its own buffer. The environment override exists
|
|
1852
|
+
// so the tests can pin the drop without sitting through the real budget.
|
|
1853
|
+
//
|
|
1854
|
+
// It is a budget per awaited frame, and what that frame waits on is everything
|
|
1855
|
+
// queued ahead of it draining — a full MAX_CLIENT_BUFFER_BYTES, by
|
|
1856
|
+
// construction, since that is what the loop fills to before it stops. So this
|
|
1857
|
+
// states a minimum rate a resuming client has to manage, and #588 doubled that
|
|
1858
|
+
// flush in practice without touching this line: the cap it is sized against was
|
|
1859
|
+
// really 4 MiB and is now the 8 MiB it always said. Still generous — measured
|
|
1860
|
+
// on loopback a full cap flushes in about a quarter of a second.
|
|
1861
|
+
const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
|
|
1862
|
+
? Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS)
|
|
1863
|
+
: 30_000;
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Write one frame of the resume stream under the same ceiling the live path
|
|
1867
|
+
* obeys, waiting rather than dropping when the client is at it. Resolves true
|
|
1868
|
+
* while the client is worth keeping, false once it is not.
|
|
1869
|
+
*
|
|
1870
|
+
* Waiting is the whole difference from writeSse, and the replay is why. The
|
|
1871
|
+
* live path writes one frame per event, so a full buffer there means the
|
|
1872
|
+
* client stopped reading and the only answer is to hang up. Here the burst is
|
|
1873
|
+
* ours: the loop below hands the socket the entire ring buffer in one turn of
|
|
1874
|
+
* the event loop, so even a client reading at full speed sees its buffer fill
|
|
1875
|
+
* — nothing has drained it yet, because nothing could. Dropping on that would
|
|
1876
|
+
* hang up on healthy clients, and hang up on them again every time they came
|
|
1877
|
+
* back: EventSource reconnects with the same Last-Event-ID, meets the same
|
|
1878
|
+
* oversized replay, and is dropped again 1.5 seconds later, forever. So we
|
|
1879
|
+
* stop writing until the socket has taken what it already has, and only give
|
|
1880
|
+
* up on a client that takes nothing at all for REPLAY_DRAIN_MS.
|
|
1881
|
+
*
|
|
1882
|
+
* The wait is on write()'s completion callback rather than on a 'drain' event:
|
|
1883
|
+
* 'drain' fires only after a write that was answered false, so waiting on it
|
|
1884
|
+
* means depending on an answer this call may never have seen. The cap sits far
|
|
1885
|
+
* above the stream's own 16 KiB high-water mark, so by the time queuedBytes is
|
|
1886
|
+
* at the cap the `false` that a 'drain' would eventually answer belongs to some
|
|
1887
|
+
* frame long since written, and its drain may already have come and gone. The
|
|
1888
|
+
* callback fires once this chunk —
|
|
1889
|
+
* and therefore everything queued ahead of it — has reached the OS, which is
|
|
1890
|
+
* exactly the condition being waited for. It also fires, with an error we do
|
|
1891
|
+
* not need to read, if the response is destroyed underneath us, so this cannot
|
|
1892
|
+
* hang on a client that goes away.
|
|
1893
|
+
*/
|
|
1894
|
+
async function writeResume(res, frame) {
|
|
1895
|
+
// Below the cap this is the plain write it has always been. The `await` in
|
|
1896
|
+
// the caller costs a microtask and nothing else: the checkpoint drains
|
|
1897
|
+
// before the loop can accept I/O, so no live event can slip between two
|
|
1898
|
+
// replay frames the way it could across a real wait.
|
|
1899
|
+
if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) {
|
|
1900
|
+
try { res.write(frame); return true; } catch { return false; }
|
|
1901
|
+
}
|
|
1902
|
+
return new Promise(resolve => {
|
|
1903
|
+
let settled = false;
|
|
1904
|
+
const done = ok => {
|
|
1905
|
+
if (settled) return;
|
|
1906
|
+
settled = true;
|
|
1907
|
+
clearTimeout(timer);
|
|
1908
|
+
resolve(ok);
|
|
1909
|
+
};
|
|
1910
|
+
// Unref'd so a client that stopped reading can never be the reason the
|
|
1911
|
+
// process stays alive, and cleared on every path out so a completed replay
|
|
1912
|
+
// leaves no timer behind.
|
|
1913
|
+
const timer = setTimeout(() => done(false), REPLAY_DRAIN_MS);
|
|
1914
|
+
timer.unref?.();
|
|
1915
|
+
try { res.write(frame, () => done(!res.destroyed)); }
|
|
1916
|
+
catch { done(false); }
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// What a redacted occurrence of the deck's token is replaced with. A visible
|
|
1921
|
+
// marker rather than an empty string, because the reader of a mangled Bash
|
|
1922
|
+
// output deserves to know the deck did it and why, and because the UI renders
|
|
1923
|
+
// payload strings verbatim — a silent hole would look like a truncated tool
|
|
1924
|
+
// response and get debugged as one.
|
|
1925
|
+
const TOKEN_REDACTED = "[redacted: ccdeck token]";
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Take the deck's own token back out of an event, before anything stores it.
|
|
1929
|
+
*
|
|
1930
|
+
* #366 made HOOK_TOKEN the one credential separating "may read" from "may
|
|
1931
|
+
* mutate", and there is a path that copies it into a store needing no
|
|
1932
|
+
* credential to read. `POST /api/event` is deliberately open — hook/hook.js is
|
|
1933
|
+
* installed outside this package, into the user's ~/.claude, so requiring a
|
|
1934
|
+
* credential there would silence every session whose hook predates that change
|
|
1935
|
+
* — while `GET /api/events`, `GET /events` and events.jsonl are all readable
|
|
1936
|
+
* without one. So an agent session that merely LOOKS at the discovery file puts
|
|
1937
|
+
* the token into the ring buffer, and that is not an exotic act: `cat
|
|
1938
|
+
* ~/.claude/agent-dag/<pid>.json`, a `Read` of it, or a plain `grep -r
|
|
1939
|
+
* ~/.claude` all do it, and CC records both the tool input and the tool
|
|
1940
|
+
* response. From the buffer it is served to exactly the two callers #366 was
|
|
1941
|
+
* written for — the other local UID and the sandboxed subprocess — neither of
|
|
1942
|
+
* which can open the discovery file itself but both of which reach the API. For
|
|
1943
|
+
* them this turned "cannot mutate" back into "can mutate", the token read out
|
|
1944
|
+
* of the event log being the whole of the new gate. What keeps those two out of
|
|
1945
|
+
* the discovery file itself — a mode bit on Unix, the profile directory's ACL
|
|
1946
|
+
* on Windows, where the chmod is a no-op — is spelled out at presentsDeckToken.
|
|
1947
|
+
*
|
|
1948
|
+
* Why this sits in pushEvent and not in handleEventIngest, which is where the
|
|
1949
|
+
* report first put it. Events reach the buffer through more than one door:
|
|
1950
|
+
* handleEventIngest is the hook's, emitCodexEvent is the Codex rollout
|
|
1951
|
+
* watcher's — those events never pass through an HTTP handler at all —
|
|
1952
|
+
* replayLog is the boot replay's, `/api/clear` pushes its own marker, and six
|
|
1953
|
+
* enrichment sites push synthetic events off the back of a transcript read.
|
|
1954
|
+
* Redacting at the ingest handler covers one of those and leaves the next route
|
|
1955
|
+
* somebody adds uncovered. pushEvent is the one point every event passes
|
|
1956
|
+
* through exactly once, so the check is written once and cannot be forgotten.
|
|
1957
|
+
*
|
|
1958
|
+
* Why a walk over the string values rather than
|
|
1959
|
+
* `JSON.stringify(payload).includes(token)`, which is the obvious one-liner.
|
|
1960
|
+
* Measured rather than assumed, on 4.7k real payloads out of a 21MB
|
|
1961
|
+
* events.jsonl (mean 5KB serialized): the walk costs 1.3–3.6 µs per event and
|
|
1962
|
+
* serialize-then-search costs 14–48 µs, the same order as the JSON.parse the
|
|
1963
|
+
* ingest path already pays for the body. Serializing here would also defeat the
|
|
1964
|
+
* deliberate optimization below, which skips serializing ENTIRELY when nothing
|
|
1965
|
+
* is subscribed and nothing is being logged — a headless deck, and the boot
|
|
1966
|
+
* replay of a log that only rotates at 50MB. The walk allocates nothing at all
|
|
1967
|
+
* until it finds something.
|
|
1968
|
+
*
|
|
1969
|
+
* Iterative rather than recursive on purpose. The payload arrives from
|
|
1970
|
+
* JSON.parse of a body capped at 5MB, which is free to nest as deeply as it
|
|
1971
|
+
* likes, and a recursive scan would be a new way to overflow the stack inside
|
|
1972
|
+
* the request listener — where nothing catches it.
|
|
1973
|
+
*
|
|
1974
|
+
* Scope is the token and nothing else in the discovery file. pid, port,
|
|
1975
|
+
* workspace, the log path and startedAt are not credentials — /api/health
|
|
1976
|
+
* already answers with the workspace path — and redacting the workspace would
|
|
1977
|
+
* mangle the `cwd` of every honest event the deck draws.
|
|
1978
|
+
*
|
|
1979
|
+
* What this does NOT catch, stated plainly so nobody mistakes it for more. A
|
|
1980
|
+
* token split across two string fields, or across two events, survives — but
|
|
1981
|
+
* every substring search has that shape, and the halves are individually
|
|
1982
|
+
* worthless: secretEquals is byte-exact, so a partial token authenticates
|
|
1983
|
+
* nothing. Case is not folded for the same reason; HOOK_TOKEN is lowercase hex
|
|
1984
|
+
* and an uppercased copy would not authenticate either.
|
|
1985
|
+
*
|
|
1986
|
+
* And this is not a new oracle. A caller who can POST here and read
|
|
1987
|
+
* /api/events can post a guess and watch whether it comes back redacted, which
|
|
1988
|
+
* tells them precisely what `x-ccdeck-token: <guess>` on any protected route
|
|
1989
|
+
* already tells them — against 256 bits with no structure to search.
|
|
1990
|
+
*
|
|
1991
|
+
* Returns the payload, because a top-level JSON string is a legal body for
|
|
1992
|
+
* `POST /api/event` and a primitive cannot be redacted in place.
|
|
1993
|
+
*/
|
|
1994
|
+
function redactDeckToken(raw) {
|
|
1995
|
+
// Read at call time, not at definition time: HOOK_TOKEN is declared far
|
|
1996
|
+
// below this line and nothing calls pushEvent during module evaluation.
|
|
1997
|
+
const token = HOOK_TOKEN;
|
|
1998
|
+
if (typeof raw === "string") return raw.includes(token) ? raw.replaceAll(token, TOKEN_REDACTED) : raw;
|
|
1999
|
+
if (raw === null || typeof raw !== "object") return raw;
|
|
2000
|
+
|
|
2001
|
+
const stack = [raw];
|
|
2002
|
+
while (stack.length > 0) {
|
|
2003
|
+
const node = stack.pop();
|
|
2004
|
+
// Arrays and objects are walked separately because an indexed loop over an
|
|
2005
|
+
// array is markedly cheaper than `for…in` over one, and this runs on every
|
|
2006
|
+
// event: a single PostToolUse response can be an array of thousands.
|
|
2007
|
+
if (Array.isArray(node)) {
|
|
2008
|
+
for (let i = 0; i < node.length; i++) {
|
|
2009
|
+
const v = node[i];
|
|
2010
|
+
if (typeof v === "string") {
|
|
2011
|
+
if (v.includes(token)) node[i] = v.replaceAll(token, TOKEN_REDACTED);
|
|
2012
|
+
} else if (v !== null && typeof v === "object") stack.push(v);
|
|
2013
|
+
}
|
|
2014
|
+
} else {
|
|
2015
|
+
// `for…in` is safe on these: they come from JSON.parse or from an object
|
|
2016
|
+
// literal in this file, so the prototype chain is Object.prototype, which
|
|
2017
|
+
// has nothing enumerable. A `"__proto__"` key in the posted JSON becomes
|
|
2018
|
+
// an OWN data property — JSON.parse does not run the setter — so it both
|
|
2019
|
+
// enumerates here and takes the assignment below as an ordinary write,
|
|
2020
|
+
// leaving the prototype alone.
|
|
2021
|
+
for (const k in node) {
|
|
2022
|
+
const v = node[k];
|
|
2023
|
+
if (typeof v === "string") {
|
|
2024
|
+
if (v.includes(token)) node[k] = v.replaceAll(token, TOKEN_REDACTED);
|
|
2025
|
+
} else if (v !== null && typeof v === "object") stack.push(v);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return raw;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function pushEvent(raw, source, opts = {}) {
|
|
2033
|
+
// First, before anything below can see it: the deck's own credential does not
|
|
2034
|
+
// belong in a store that is served without one. Every entry point to the
|
|
2035
|
+
// buffer, the SSE fan-out and events.jsonl passes through here, so this is
|
|
2036
|
+
// the single line that keeps it out of all three. See redactDeckToken.
|
|
2037
|
+
raw = redactDeckToken(raw);
|
|
2038
|
+
|
|
2039
|
+
// Synchronous enrichment: if we already know this session's model, stamp
|
|
2040
|
+
// it on the payload so the client's recursive scanner picks it up.
|
|
2041
|
+
if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
|
|
2042
|
+
const modelId = cachedModelId(modelBySession.get(raw.session_id));
|
|
2043
|
+
if (modelId) raw.model = modelId;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const seq = nextSeq++;
|
|
2047
|
+
const evt = {
|
|
2048
|
+
seq,
|
|
2049
|
+
epoch: SEQ_EPOCH,
|
|
2050
|
+
receivedAt: opts.receivedAt ?? Date.now(),
|
|
2051
|
+
source,
|
|
2052
|
+
payload: raw,
|
|
2053
|
+
};
|
|
2054
|
+
events.push(evt);
|
|
2055
|
+
if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
|
|
2056
|
+
|
|
2057
|
+
// Does this event reach the log at all? Not on a replay (it came from
|
|
2058
|
+
// there), not when the hook told us another deck owns this session's log,
|
|
2059
|
+
// and not when we have no log. Decided before serializing because it is half
|
|
2060
|
+
// of the answer to whether serializing is worth doing.
|
|
2061
|
+
const persisting = persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw);
|
|
2062
|
+
|
|
2063
|
+
// One serialization, shared by both consumers — and skipped entirely when
|
|
2064
|
+
// neither wants it. This used to stringify the whole envelope twice on the
|
|
2065
|
+
// hottest path in the process (once for the SSE frame, once for the persist
|
|
2066
|
+
// line), and built the frame even with nobody subscribed: a headless deck
|
|
2067
|
+
// paid a full stringify per event for a string no one read, and boot replay
|
|
2068
|
+
// — which runs before the listener exists and never broadcasts — paid one
|
|
2069
|
+
// for every line of a log that rotates at 50MB. An event this deck is not
|
|
2070
|
+
// logging is still broadcast, so a subscriber alone is reason enough.
|
|
2071
|
+
const json = (sseClients.size > 0 || persisting) ? JSON.stringify(evt) : null;
|
|
2072
|
+
|
|
2073
|
+
if (sseClients.size > 0) {
|
|
2074
|
+
const line = `id: ${seq}\nevent: hook\ndata: ${json}\n\n`;
|
|
2075
|
+
// writeSse may drop a client mid-loop; deleting from a Set while iterating
|
|
2076
|
+
// it is well defined and skips only the entry removed.
|
|
2077
|
+
for (const res of sseClients) writeSse(res, line);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
if (persisting) {
|
|
2081
|
+
// Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
|
|
2082
|
+
// has to reach the file as one write — this used to be `appendFile`, which
|
|
2083
|
+
// splits anything over 512 KiB into separate appends and let another
|
|
2084
|
+
// event land in the middle of a large tool response. See appendLogLine.
|
|
2085
|
+
//
|
|
2086
|
+
// Note this runs AFTER redactDeckToken above, as every path to the log
|
|
2087
|
+
// does: the string being written is the one serialization of the event the
|
|
2088
|
+
// SSE frame also used, and the token was taken out of the payload before
|
|
2089
|
+
// either existed.
|
|
2090
|
+
appendLogLine(persistPath, json + "\n");
|
|
2091
|
+
// Cheap throttled check (every 30s) — only rotates if file > 50MB.
|
|
2092
|
+
maybeRotatePersistFile();
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Note the session so the caches the scanners below fill can expire by
|
|
2096
|
+
// least-recent use. Replays are excluded: they fill nothing, and a boot
|
|
2097
|
+
// replay of a log spanning weeks would otherwise churn the whole LRU through
|
|
2098
|
+
// dead session ids before the first live event even arrives.
|
|
2099
|
+
if (!opts.replay && raw && typeof raw === "object") touchSession(raw.session_id);
|
|
2100
|
+
|
|
2101
|
+
// Kick off async transcript scans. Model and usage are both re-read
|
|
2102
|
+
// periodically (throttled to 2.5s per session) so the cost columns track
|
|
2103
|
+
// running totals as the session progresses and late subagent models still
|
|
2104
|
+
// land; ModelObserved is only emitted when the resolved set changes. Both
|
|
2105
|
+
// result in synthetic events.
|
|
2106
|
+
// Provider gates the path: Claude reads transcript_path; Codex reads its
|
|
2107
|
+
// rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
|
|
2108
|
+
// when transcript_path is absent (always the case for Codex hooks).
|
|
2109
|
+
if (source === "hook" && !opts.replay) {
|
|
2110
|
+
if (raw && raw.provider === "codex") {
|
|
2111
|
+
maybeResolveCodex(raw);
|
|
2112
|
+
} else {
|
|
2113
|
+
maybeResolveModel(raw);
|
|
2114
|
+
maybeResolveUsage(raw);
|
|
2115
|
+
maybeResolveContext(raw);
|
|
2116
|
+
maybeResolveSessionName(raw);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
return evt;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Read the log back into the ring buffer at boot.
|
|
2125
|
+
*
|
|
2126
|
+
* A line that will not parse is skipped rather than thrown on, and that is
|
|
2127
|
+
* deliberate — it is what lets a log damaged by the pre-#446 writer still
|
|
2128
|
+
* replay. Every line before the damage and every line after it is whole, so
|
|
2129
|
+
* stopping at the first bad one would throw away the rest of the session for a
|
|
2130
|
+
* fault that costs one event. Newline framing is what makes the recovery
|
|
2131
|
+
* possible: a torn write leaves the reader resynchronised at the very next
|
|
2132
|
+
* `\n`, with no length prefix to have been lost along with the bytes.
|
|
2133
|
+
*
|
|
2134
|
+
* What changed is that the skip is no longer silent. Before, a deck whose log
|
|
2135
|
+
* had been shredded replayed "mostly" and said nothing anywhere: the largest
|
|
2136
|
+
* tool responses and whatever ordinary event was spliced into them were gone,
|
|
2137
|
+
* with no counter and no line on the terminal. The count below is the only
|
|
2138
|
+
* signal that a log carries damage from a writer that has since been fixed, and
|
|
2139
|
+
* the byte total is what tells the user whether it was one truncated tail from
|
|
2140
|
+
* a kill -9 or a megabyte of shredded tool output.
|
|
2141
|
+
*
|
|
2142
|
+
* Deliberately one line, and deliberately without the path in it: this prints
|
|
2143
|
+
* onto a terminal the deck is about to paint over (see oneLine in term.mjs for
|
|
2144
|
+
* what a multi-line message does there), and the path was already printed at
|
|
2145
|
+
* boot by the caller.
|
|
2146
|
+
*/
|
|
2147
|
+
async function replayLog(filePath) {
|
|
2148
|
+
if (!existsSync(filePath)) return 0;
|
|
2149
|
+
let count = 0;
|
|
2150
|
+
let skipped = 0;
|
|
2151
|
+
let skippedBytes = 0;
|
|
2152
|
+
const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
|
|
2153
|
+
for await (const line of rl) {
|
|
2154
|
+
if (!line) continue;
|
|
2155
|
+
try {
|
|
2156
|
+
const evt = JSON.parse(line);
|
|
2157
|
+
if (evt && typeof evt === "object" && evt.payload) {
|
|
2158
|
+
pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
|
|
2159
|
+
count++;
|
|
2160
|
+
}
|
|
2161
|
+
} catch {
|
|
2162
|
+
skipped++;
|
|
2163
|
+
skippedBytes += Buffer.byteLength(line, "utf8");
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
if (skipped > 0) {
|
|
2167
|
+
const kb = (skippedBytes / 1024).toFixed(0);
|
|
2168
|
+
console.warn(`${PRODUCT}: skipped ${skipped} unreadable line(s) (${kb}KB) while replaying the event log`);
|
|
2169
|
+
}
|
|
2170
|
+
return count;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function send(res, status, body, headers = {}) {
|
|
2174
|
+
res.writeHead(status, {
|
|
2175
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
2176
|
+
"Cache-Control": "no-store",
|
|
2177
|
+
...headers,
|
|
2178
|
+
});
|
|
2179
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
async function serveStatic(req, res, url) {
|
|
2183
|
+
// Strip leading slash, default to index.html
|
|
2184
|
+
let rel = url.pathname.replace(/^\/+/, "");
|
|
2185
|
+
if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
|
|
2186
|
+
const filePath = join(WEB_DIST, rel);
|
|
2187
|
+
if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
|
|
2188
|
+
|
|
2189
|
+
try {
|
|
2190
|
+
const s = await stat(filePath);
|
|
2191
|
+
if (s.isDirectory()) return send(res, 404, { error: "not found" });
|
|
2192
|
+
const buf = await readFile(filePath);
|
|
2193
|
+
res.writeHead(200, {
|
|
2194
|
+
"Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
|
|
2195
|
+
"Cache-Control": "no-cache",
|
|
2196
|
+
});
|
|
2197
|
+
res.end(buf);
|
|
2198
|
+
} catch {
|
|
2199
|
+
// SPA fallback to index.html for client-side routes
|
|
2200
|
+
try {
|
|
2201
|
+
const idx = await readFile(join(WEB_DIST, "index.html"));
|
|
2202
|
+
// Same no-cache as the normal path above, which this used to omit. It
|
|
2203
|
+
// matters more since the deck upgrades itself: index.html is the file
|
|
2204
|
+
// naming the hashed bundle, so a heuristically-cached copy sends the tab
|
|
2205
|
+
// back to the OLD assets after an update and the reload achieves nothing.
|
|
2206
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
2207
|
+
res.end(idx);
|
|
2208
|
+
} catch {
|
|
2209
|
+
send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
/** Collect a request body as a string, capped so a bad client can't fill memory. */
|
|
2215
|
+
function readBody(req, limit = 64_000) {
|
|
2216
|
+
return new Promise((resolve, reject) => {
|
|
2217
|
+
let body = "";
|
|
2218
|
+
req.setEncoding("utf8");
|
|
2219
|
+
req.on("data", c => {
|
|
2220
|
+
body += c;
|
|
2221
|
+
if (body.length > limit) { req.destroy(); reject(new Error("body too large")); }
|
|
2222
|
+
});
|
|
2223
|
+
req.on("end", () => resolve(body));
|
|
2224
|
+
req.on("error", reject);
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// How long an oversized POST is drained after it has been refused, so the 413
|
|
2229
|
+
// reaches a poster that is still uploading. Generous next to hook.js's own
|
|
2230
|
+
// one-second budget, and finite so nothing can sit on the socket indefinitely.
|
|
2231
|
+
const OVERSIZE_DRAIN_MS = 10_000;
|
|
2232
|
+
|
|
2233
|
+
// `persist` is false when the hook posted this event to another deck as well
|
|
2234
|
+
// and elected that one to write it to the log they share. The event is still
|
|
2235
|
+
// buffered and broadcast here — every matching deck draws it — it is only the
|
|
2236
|
+
// second copy on disk that is dropped.
|
|
2237
|
+
function handleEventIngest(req, res, persist = true) {
|
|
2238
|
+
let body = "";
|
|
2239
|
+
// Set once the cap is hit, because everything after that point is about an
|
|
2240
|
+
// exchange that is already over: more `data` may still be in flight, and
|
|
2241
|
+
// `end` must not go on to parse the truncated half.
|
|
2242
|
+
let refused = false;
|
|
2243
|
+
req.setEncoding("utf8");
|
|
2244
|
+
req.on("data", c => {
|
|
2245
|
+
if (refused) return;
|
|
2246
|
+
body += c;
|
|
2247
|
+
if (body.length > 5_000_000) {
|
|
2248
|
+
refused = true;
|
|
2249
|
+
body = ""; // nothing will read it now; let it go
|
|
2250
|
+
// ANSWER, rather than vanish. `req.destroy()` on its own tore the socket
|
|
2251
|
+
// down with no status line on it at all, so the poster learned only that
|
|
2252
|
+
// the connection had gone — indistinguishable from a deck that died or
|
|
2253
|
+
// was never there, and nothing in the exchange to tell those apart. `end`
|
|
2254
|
+
// never fires on a destroyed request either, so the handler below got no
|
|
2255
|
+
// second chance to speak. readBody above gets this right already, by
|
|
2256
|
+
// rejecting into a caller that replies.
|
|
2257
|
+
send(res, 413, { error: "event too large" });
|
|
2258
|
+
// Then keep reading, and throw it away. Answering is not enough on its
|
|
2259
|
+
// own, because the poster is still mid-upload when the answer goes out:
|
|
2260
|
+
// hang up now and its next write lands on a dead socket, it aborts with
|
|
2261
|
+
// EPIPE, and the 413 that was already sitting in its receive buffer is
|
|
2262
|
+
// discarded unread — the same disappearance in a different costume.
|
|
2263
|
+
// `Connection: close` is the tempting version of hanging up and has
|
|
2264
|
+
// exactly that effect: Node destroys the socket the moment such a
|
|
2265
|
+
// response flushes. Draining lets the poster finish and then read the
|
|
2266
|
+
// answer, which is the whole point of answering. `body` no longer grows,
|
|
2267
|
+
// so it costs no memory.
|
|
2268
|
+
req.resume();
|
|
2269
|
+
// Bounded, because draining forever is its own denial of service: a
|
|
2270
|
+
// poster that stops without ending would otherwise hold the socket for as
|
|
2271
|
+
// long as it liked.
|
|
2272
|
+
const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
|
|
2273
|
+
grace.unref?.();
|
|
2274
|
+
req.on("close", () => clearTimeout(grace));
|
|
2275
|
+
}
|
|
2276
|
+
});
|
|
2277
|
+
req.on("end", () => {
|
|
2278
|
+
if (refused) return;
|
|
2279
|
+
let parsed;
|
|
2280
|
+
try { parsed = JSON.parse(body); }
|
|
2281
|
+
catch { return send(res, 400, { error: "invalid json" }); }
|
|
2282
|
+
noteLogWriter(parsed, persist);
|
|
2283
|
+
const evt = pushEvent(parsed, "hook", { persist });
|
|
2284
|
+
send(res, 200, { ok: true, seq: evt.seq });
|
|
2285
|
+
});
|
|
2286
|
+
// Guarded for the same reason `end` is, and more sharply: destroying the
|
|
2287
|
+
// request above is itself what raises this, and answering a second time on a
|
|
2288
|
+
// response already sent throws ERR_HTTP_HEADERS_SENT out of an error handler,
|
|
2289
|
+
// where nothing is waiting to catch it.
|
|
2290
|
+
req.on("error", () => { if (!refused) send(res, 400, { error: "bad request" }); });
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
function handleSse(req, res) {
|
|
2294
|
+
res.writeHead(200, {
|
|
2295
|
+
"Content-Type": "text/event-stream",
|
|
2296
|
+
"Cache-Control": "no-cache, no-transform",
|
|
2297
|
+
"Connection": "keep-alive",
|
|
2298
|
+
"X-Accel-Buffering": "no",
|
|
2299
|
+
});
|
|
2300
|
+
res.write(`retry: 1500\n\n`);
|
|
2301
|
+
|
|
2302
|
+
// A stale or absent id replays the whole ring, and so does a malformed one:
|
|
2303
|
+
// Number("nonsense") is NaN, every `seq <= NaN` is false, and the catch-up
|
|
2304
|
+
// loop below would rather compare against a number.
|
|
2305
|
+
const asked = Number(req.headers["last-event-id"] ?? 0);
|
|
2306
|
+
const lastId = Number.isFinite(asked) ? asked : 0;
|
|
2307
|
+
|
|
2308
|
+
// The replay waits on the socket now, so it can no longer be part of this
|
|
2309
|
+
// synchronous handler. Nothing is waiting on the result here — the response
|
|
2310
|
+
// is already committed to a 200 and its own failure path is to hang up — so
|
|
2311
|
+
// start it, keep the router's contract of returning nothing, and make sure a
|
|
2312
|
+
// rejection ends the stream rather than the process.
|
|
2313
|
+
resumeSse(req, res, lastId).catch(() => dropSse(res));
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Drain the ring buffer into a newly connected client, then subscribe it.
|
|
2318
|
+
*
|
|
2319
|
+
* Two things had to change when this stopped being one synchronous burst.
|
|
2320
|
+
* `close` is registered before the first frame, because a tab closed mid-replay
|
|
2321
|
+
* has to stop it. And the replay repeats until it reaches the live tail: an
|
|
2322
|
+
* actual wait lets pushEvent run, and an event that lands after we have walked
|
|
2323
|
+
* past its place but before the client is in `sseClients` would otherwise be in
|
|
2324
|
+
* neither stream — a hole the client cannot even ask for again, its last id
|
|
2325
|
+
* having moved past it.
|
|
2326
|
+
*/
|
|
2327
|
+
async function resumeSse(req, res, lastId) {
|
|
2328
|
+
let sentThrough = lastId;
|
|
2329
|
+
let ping = null;
|
|
2330
|
+
let closed = false;
|
|
2331
|
+
req.on("close", () => {
|
|
2332
|
+
closed = true;
|
|
2333
|
+
if (ping) clearInterval(ping);
|
|
2334
|
+
sseClients.delete(res);
|
|
2335
|
+
});
|
|
2336
|
+
|
|
2337
|
+
for (;;) {
|
|
2338
|
+
// A snapshot per pass, because a wait lets pushEvent splice the head of
|
|
2339
|
+
// `events` off, and iterating an array being spliced from the front skips
|
|
2340
|
+
// entries. Events evicted that way are gone for this client, which is the
|
|
2341
|
+
// same bargain every resume against a rotated ring already makes.
|
|
2342
|
+
const batch = events.slice();
|
|
2343
|
+
for (const e of batch) {
|
|
2344
|
+
if (e.seq <= sentThrough) continue;
|
|
2345
|
+
if (closed || res.destroyed) return;
|
|
2346
|
+
// Marked with `replay:true` on the envelope so the client can suppress
|
|
2347
|
+
// turn-cleanup side effects (exitAt stamping, autofit churn) until the
|
|
2348
|
+
// live stream takes over. Without this the reducer's UserPromptSubmit
|
|
2349
|
+
// handler treats replayed events as a real new turn — hiding prior-turn
|
|
2350
|
+
// subagents using the event's stale receivedAt, which collides with
|
|
2351
|
+
// wall-clock visibility gates and yields the "nodes appear then vanish"
|
|
2352
|
+
// symptom on refresh.
|
|
2353
|
+
const tagged = { ...e, replay: true };
|
|
2354
|
+
if (!await writeResume(res, `id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`)) {
|
|
2355
|
+
return dropSse(res);
|
|
2356
|
+
}
|
|
2357
|
+
sentThrough = e.seq;
|
|
2358
|
+
}
|
|
2359
|
+
// Caught up with the tail as it stands right now. Reached in one pass
|
|
2360
|
+
// unless a wait let new events in, and it terminates for the same reason
|
|
2361
|
+
// the client is still here at all: either it is taking bytes, in which case
|
|
2362
|
+
// loopback outruns any hook, or it is not, in which case writeResume gives
|
|
2363
|
+
// up on it.
|
|
2364
|
+
if (events.length === 0 || events[events.length - 1].seq <= sentThrough) break;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
if (closed || res.destroyed) return;
|
|
2368
|
+
|
|
2369
|
+
// Sentinel: tells client "ring buffer drained, live stream starts now". It
|
|
2370
|
+
// goes out under the same rule as the frames before it, so a client that has
|
|
2371
|
+
// just been handed a large replay is not hung up on over the last 30 bytes of
|
|
2372
|
+
// it before it has had the chance to read any.
|
|
2373
|
+
//
|
|
2374
|
+
// Subscribing before waiting on that write, rather than after, is what closes
|
|
2375
|
+
// the last hole: writeResume puts the bytes on the socket before it returns,
|
|
2376
|
+
// so the sentinel still precedes every live frame, and an event pushed while
|
|
2377
|
+
// we wait reaches this client through the live fan-out instead of falling
|
|
2378
|
+
// into the gap between the two. If the wait then ends in a drop, dropSse
|
|
2379
|
+
// takes it back out of the set — the same exit writeSse uses.
|
|
2380
|
+
const flushed = writeResume(res, `event: replay-end\ndata: {}\n\n`);
|
|
2381
|
+
sseClients.add(res);
|
|
2382
|
+
// Through writeSse like every other frame: on a client that has stopped
|
|
2383
|
+
// reading, the ping is the one thing still being written between events, and
|
|
2384
|
+
// it is what eventually reveals the socket as unrecoverable.
|
|
2385
|
+
ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
|
|
2386
|
+
if (!await flushed) dropSse(res);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// True only when a supervisor is listening AND the event log is being written.
|
|
2390
|
+
// Without persistence a restart wipes the canvas irrecoverably — replayLog has
|
|
2391
|
+
// no file to read — so the deck must not offer to do it.
|
|
2392
|
+
let _canRestart = false;
|
|
2393
|
+
|
|
2394
|
+
async function handleVersion(req, res) {
|
|
2395
|
+
const { versionReport } = await import(
|
|
2396
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
2397
|
+
);
|
|
2398
|
+
// ?refresh=1 asks npm now rather than reusing the cached answer — what the
|
|
2399
|
+
// version chip does when clicked, for the user who has just published or is
|
|
2400
|
+
// wondering whether the check is working at all.
|
|
2401
|
+
const force = new URL(req.url, "http://localhost").searchParams.get("refresh") === "1";
|
|
2402
|
+
const report = await versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT, force });
|
|
2403
|
+
// Attached here rather than inside versionReport for the same reason
|
|
2404
|
+
// canRestart is: the report is what this package root says about itself,
|
|
2405
|
+
// while both of these are facts about the process serving it — the command
|
|
2406
|
+
// the user typed lives in this process's environment and argv, not on disk.
|
|
2407
|
+
//
|
|
2408
|
+
// It is deliberately not folded into `name`, which is documented as the
|
|
2409
|
+
// package an upgrade would install and is load-bearing for the update flow.
|
|
2410
|
+
// The two answer different questions and give different answers: `name` is
|
|
2411
|
+
// read off the install on disk and is always known, while this one is the
|
|
2412
|
+
// command that was typed and is silent wherever it cannot be proven — every
|
|
2413
|
+
// Windows global install, and every `npm i -g ccdeck`, whose stub spawns the
|
|
2414
|
+
// deck by absolute path and leaves nothing in argv to read.
|
|
2415
|
+
const invoked = invokedName({ pkgRoot: PKG_ROOT });
|
|
2416
|
+
// The second half of that notice, computed here rather than in the browser.
|
|
2417
|
+
//
|
|
2418
|
+
// The banner needs one more fact than the name: whether typing `ccdeck` is
|
|
2419
|
+
// enough (a global install ships all three commands, so it already is) or
|
|
2420
|
+
// whether the whole answer is `npx ccdeck`. The browser used to derive that
|
|
2421
|
+
// from `upgradeMode`, which reads like the same question and is not — it
|
|
2422
|
+
// answers "may this copy install over itself", and `AGENTS_DECK_NO_INSTALL=1`
|
|
2423
|
+
// makes it null for EVERY shape including npx, because upgradeBlockedReason
|
|
2424
|
+
// tests the opt-out before it tests npx. So an npx user who opted out of
|
|
2425
|
+
// installs was told the global-install line and sent to a command that does
|
|
2426
|
+
// not exist on their machine, while the terminal row two feet away said
|
|
2427
|
+
// `npx ccdeck` — one fact, two surfaces, opposite answers (#363).
|
|
2428
|
+
//
|
|
2429
|
+
// renameNotice is where that fact is decided for the terminal, so it decides
|
|
2430
|
+
// it here too and the browser renders the string rather than a branch. No
|
|
2431
|
+
// `dash` argument on purpose: the glyph tier is a terminal concern, and the
|
|
2432
|
+
// default em dash is what a browser should get.
|
|
2433
|
+
const rename = renameNotice({ invoked, pkgRoot: PKG_ROOT });
|
|
2434
|
+
send(res, 200, {
|
|
2435
|
+
...report, canRestart: _canRestart, invokedAs: invoked, renameFix: rename?.fix ?? null,
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// Runs `npm i -g <this deck>@latest`, and only that: the argument vector is
|
|
2440
|
+
// fixed inside self-update.mjs — including which of the three published names
|
|
2441
|
+
// it installs, which comes from the layout npm built rather than from the
|
|
2442
|
+
// request. Answers immediately — progress is read back from /api/version.
|
|
2443
|
+
async function handleUpgrade(_req, res) {
|
|
2444
|
+
const { startUpgrade } = await import(
|
|
2445
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
2446
|
+
);
|
|
2447
|
+
const out = startUpgrade({ pkgRoot: PKG_ROOT });
|
|
2448
|
+
send(res, out.ok ? 200 : 409, out);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
// Restart is a two-party act: this half answers before it stops listening, so
|
|
2452
|
+
// the caller learns it was accepted rather than losing the socket mid-reply.
|
|
2453
|
+
//
|
|
2454
|
+
// `{ upgrade: true }` asks for the npx variant: come back through
|
|
2455
|
+
// `npx -y <spec>@latest` instead of re-running the files already here. Granted
|
|
2456
|
+
// only where that is genuinely how this copy updates — the mode is decided from
|
|
2457
|
+
// the install on disk, never from the request.
|
|
2458
|
+
async function handleRestart(req, res) {
|
|
2459
|
+
if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
|
|
2460
|
+
// Enforced here and not only in the UI. Under --no-persist a restart destroys
|
|
2461
|
+
// the whole canvas — replayLog has no file to read — and a destructive act
|
|
2462
|
+
// must not be prevented by a hidden button alone.
|
|
2463
|
+
if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
|
|
2464
|
+
|
|
2465
|
+
let wantUpgrade = false;
|
|
2466
|
+
if (req.method === "POST") {
|
|
2467
|
+
const body = await readBody(req).catch(() => null);
|
|
2468
|
+
try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
|
|
2469
|
+
}
|
|
2470
|
+
let mode = null;
|
|
2471
|
+
if (wantUpgrade) {
|
|
2472
|
+
const { upgradeBlock, upgradeMode, npxRestartSpec } = await import(
|
|
2473
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
2474
|
+
);
|
|
2475
|
+
if (upgradeMode(upgradeBlock(PKG_ROOT)) !== "npx" || !npxRestartSpec(PKG_ROOT)) {
|
|
2476
|
+
return send(res, 409, { ok: false, reason: "not_npx" });
|
|
2477
|
+
}
|
|
2478
|
+
mode = "npx";
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
if (_restarting) return send(res, 202, { ok: true, already: true });
|
|
2482
|
+
_restarting = true;
|
|
2483
|
+
// Accepted either way — the ask is good and the launcher holds on to it — but
|
|
2484
|
+
// the two are not the same event and answering 200 to both would be this
|
|
2485
|
+
// window's second untruth rather than its first. Between this listener
|
|
2486
|
+
// accepting its first connection and bin/deck.js finishing the rest of its
|
|
2487
|
+
// startup there is a real stretch during which a restart cannot be run yet,
|
|
2488
|
+
// and a caller that reads the body should be able to tell that it is waiting
|
|
2489
|
+
// on a boot rather than on a supervisor. See markDeckReady, and requestRestart
|
|
2490
|
+
// in bin/deck.js for the half that does the waiting.
|
|
2491
|
+
if (_deckReady) send(res, 200, { ok: true, mode });
|
|
2492
|
+
else send(res, 202, { ok: true, mode, booting: true, detail: "the deck is still starting up; the restart runs as soon as it has finished booting" });
|
|
2493
|
+
// Let the response flush before the listener goes away.
|
|
2494
|
+
setTimeout(() => {
|
|
2495
|
+
try { _onRestart(mode); }
|
|
2496
|
+
catch (err) {
|
|
2497
|
+
// This catch is where #448 lived: it released the server's half of the
|
|
2498
|
+
// latch and said nothing, while the launcher's half stayed set with
|
|
2499
|
+
// nothing left to clear it, and every later restart answered "ok" and did
|
|
2500
|
+
// nothing for the rest of the process's life. The launcher now owns its
|
|
2501
|
+
// own failures; this stays as the outer net, and it says so — one line,
|
|
2502
|
+
// message only, because the terminal underneath is repainted every 800ms
|
|
2503
|
+
// by the pulse and a stack dumped into it is a stack nobody can read.
|
|
2504
|
+
_restarting = false;
|
|
2505
|
+
console.error(`${PRODUCT}: restart request failed: ${err?.message ?? err}`);
|
|
2506
|
+
}
|
|
2507
|
+
}, 120).unref();
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
/** The other end of the latch above, for the upgrade that never happened.
|
|
2511
|
+
*
|
|
2512
|
+
* An npx upgrade is now fetched before anything is torn down, so a fetch that
|
|
2513
|
+
* fails leaves this process serving — with `_restarting` still set from a
|
|
2514
|
+
* restart that is not coming. Nothing would ever clear it: the only path that
|
|
2515
|
+
* did was the process ending. Every later click, from any tab, answered 202
|
|
2516
|
+
* "already restarting" for the rest of the deck's life. */
|
|
2517
|
+
export function releaseRestart() {
|
|
2518
|
+
_restarting = false;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/** The launcher saying its boot is over: everything a shutdown would have to
|
|
2522
|
+
* tear down now exists.
|
|
2523
|
+
*
|
|
2524
|
+
* This server starts accepting from inside startServer, before that call has
|
|
2525
|
+
* even returned to bin/deck.js — so /api/restart is answerable for the whole
|
|
2526
|
+
* of the startup that follows it, which on a cold boot includes the discovery
|
|
2527
|
+
* file's first fsynced write and spawning the browser. The listener cannot see
|
|
2528
|
+
* any of that from here; it has to be told. Called once, from bin/deck.js.
|
|
2529
|
+
*
|
|
2530
|
+
* Only /api/restart reads it, and only to answer honestly. Nothing is refused
|
|
2531
|
+
* on the strength of it: the restart is still handed to the launcher, which
|
|
2532
|
+
* holds it until it can run it (#448). */
|
|
2533
|
+
export function markDeckReady() {
|
|
2534
|
+
_deckReady = true;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
async function handleQuota(req, res) {
|
|
2538
|
+
const { fetchClaudeQuota } = await import(
|
|
2539
|
+
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
2540
|
+
);
|
|
2541
|
+
const url = new URL(req.url, "http://localhost");
|
|
2542
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
2543
|
+
const quota = await fetchClaudeQuota({ force });
|
|
2544
|
+
send(res, 200, quota);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
async function handleCodexUsage(req, res) {
|
|
2548
|
+
const { fetchCodexUsage } = await import(
|
|
2549
|
+
pathToFileURL(join(PKG_ROOT, "src/server/codex-usage.mjs")).href
|
|
2550
|
+
);
|
|
2551
|
+
const url = new URL(req.url, "http://localhost");
|
|
2552
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
2553
|
+
const usage = await fetchCodexUsage({ force });
|
|
2554
|
+
send(res, 200, usage);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
async function handleCodexQuota(req, res) {
|
|
2558
|
+
const { fetchCodexQuota } = await import(
|
|
2559
|
+
pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
|
|
2560
|
+
);
|
|
2561
|
+
const url = new URL(req.url, "http://localhost");
|
|
2562
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
2563
|
+
const quota = await fetchCodexQuota({ force });
|
|
2564
|
+
send(res, 200, quota);
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
/**
|
|
2568
|
+
* ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
|
|
2569
|
+
* at all — an absent parameter lets ccusage.mjs pick the default 30-day range.
|
|
2570
|
+
*
|
|
2571
|
+
* Eight digits is a shape, not a calendar, and deliberately so. What this gate
|
|
2572
|
+
* is for is keeping anything that is not a date out of a child process's
|
|
2573
|
+
* argument vector; `99999999` is exactly as inert an argument as `20260101`,
|
|
2574
|
+
* and whether a date outside the logs means an empty chart is ccusage's
|
|
2575
|
+
* question to answer rather than the deck's to guess at.
|
|
2576
|
+
*/
|
|
2577
|
+
export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
|
|
2578
|
+
|
|
2579
|
+
async function handleCcusage(req, res) {
|
|
2580
|
+
const url = new URL(req.url, "http://localhost");
|
|
2581
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
2582
|
+
const since = url.searchParams.get("since") || undefined;
|
|
2583
|
+
const until = url.searchParams.get("until") || undefined;
|
|
2584
|
+
// Refused here, at the boundary, because this is where a string chosen by
|
|
2585
|
+
// someone else enters: /api/ccusage is a GET, so it needs no CORS, no
|
|
2586
|
+
// preflight and no ability to read the response — any page the user has open
|
|
2587
|
+
// can fire it at the loopback port — and both values end up in the argv of a
|
|
2588
|
+
// spawned CLI. The spawn no longer goes through a shell (see ccusage.mjs), so
|
|
2589
|
+
// this is the second lock rather than the only one, and it is the one that
|
|
2590
|
+
// keeps the pair honest if a shell is ever reintroduced downstream.
|
|
2591
|
+
if (!isCliDate(since) || !isCliDate(until)) {
|
|
2592
|
+
return send(res, 400, {
|
|
2593
|
+
ok: false,
|
|
2594
|
+
reason: "bad_range",
|
|
2595
|
+
error: "since and until must be YYYYMMDD dates, e.g. 20260101",
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
const { fetchCcusageDaily } = await import(
|
|
2599
|
+
pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
|
|
2600
|
+
);
|
|
2601
|
+
const data = await fetchCcusageDaily({ since, until, force });
|
|
2602
|
+
send(res, 200, data);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
async function handleClaudeAccounts(req, res) {
|
|
2606
|
+
const { fetchClaudeAccounts } = await import(
|
|
2607
|
+
pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
|
|
2608
|
+
);
|
|
2609
|
+
const url = new URL(req.url, "http://localhost");
|
|
2610
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
2611
|
+
send(res, 200, await fetchClaudeAccounts({ force }));
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
async function handleClaudeAccountSwitch(req, res) {
|
|
2615
|
+
const { switchClaudeAccount, invalidateClaudeAccountsCache } = await import(
|
|
2616
|
+
pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
|
|
2617
|
+
);
|
|
2618
|
+
const body = await readBody(req).catch(() => null);
|
|
2619
|
+
let parsed = null;
|
|
2620
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
2621
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
2622
|
+
|
|
2623
|
+
const result = await switchClaudeAccount(parsed.account);
|
|
2624
|
+
// The active account just moved; the next poll should see it immediately
|
|
2625
|
+
// rather than serving the pre-switch roster for another few seconds.
|
|
2626
|
+
invalidateClaudeAccountsCache();
|
|
2627
|
+
// The 5h/7d percentages moved with it — they are read for whichever account is
|
|
2628
|
+
// active — and only this side knows: /api/quota is polled by the usage panel,
|
|
2629
|
+
// which has no idea the accounts panel just switched and so never sends
|
|
2630
|
+
// ?refresh=1. Without this the two panels sit on one screen disagreeing about
|
|
2631
|
+
// the same account for a full poll cycle, and the stale one is the one the
|
|
2632
|
+
// user just acted on. Only on a switch that took: a refused one left the
|
|
2633
|
+
// account where it was, and throwing away a reading we paid for costs a real
|
|
2634
|
+
// answer to fix nothing.
|
|
2635
|
+
if (result.ok) {
|
|
2636
|
+
const { invalidateQuotaCache } = await import(
|
|
2637
|
+
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
2638
|
+
);
|
|
2639
|
+
invalidateQuotaCache();
|
|
2640
|
+
}
|
|
2641
|
+
send(res, result.ok ? 200 : 400, result);
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
function cswapAdminModule() {
|
|
2645
|
+
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// Reading the login's progress. The browser polls this while its dialog is
|
|
2649
|
+
// open, the same way the upgrade notice polls /api/version.
|
|
2650
|
+
async function handleAccountLoginState(_req, res) {
|
|
2651
|
+
const { loginState } = await cswapAdminModule();
|
|
2652
|
+
send(res, 200, { ok: true, ...loginState() });
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* Everything that changes the account store, behind one verb switch — the shape
|
|
2657
|
+
* handleCswapAutoAction already uses.
|
|
2658
|
+
*
|
|
2659
|
+
* The sign-in code is the one field here that is a credential. It is read out
|
|
2660
|
+
* of the body, handed straight to the child's stdin, and never logged, echoed
|
|
2661
|
+
* back, or written anywhere — which is also why this is a POST body and not a
|
|
2662
|
+
* query parameter.
|
|
2663
|
+
*/
|
|
2664
|
+
async function handleClaudeAccountAdmin(req, res) {
|
|
2665
|
+
const admin = await cswapAdminModule();
|
|
2666
|
+
const body = await readBody(req).catch(() => null);
|
|
2667
|
+
let parsed = null;
|
|
2668
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
2669
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
2670
|
+
|
|
2671
|
+
let result;
|
|
2672
|
+
switch (parsed.action) {
|
|
2673
|
+
case "login": result = await admin.startLogin({ email: parsed.email }); break;
|
|
2674
|
+
case "login-code": result = await admin.submitLoginCode(parsed.code); break;
|
|
2675
|
+
case "login-cancel": result = await admin.cancelLogin(); break;
|
|
2676
|
+
case "share": result = await admin.shareAccount(parsed.account); break;
|
|
2677
|
+
case "import": result = await admin.importAccount(parsed.blob); break;
|
|
2678
|
+
case "remove": result = await admin.removeAccount(parsed.account); break;
|
|
2679
|
+
case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
|
|
2680
|
+
case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
|
|
2681
|
+
default: return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
2682
|
+
}
|
|
2683
|
+
send(res, result.ok ? 200 : 400, result);
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
function cswapAutoModule() {
|
|
2687
|
+
return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
async function handleCswapAuto(req, res) {
|
|
2691
|
+
const { autoStatus } = await cswapAutoModule();
|
|
2692
|
+
send(res, 200, await autoStatus());
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
/**
|
|
2696
|
+
* One POST for every auto-switch control, keyed by `action`. Each one can move
|
|
2697
|
+
* the user's live Claude account or change when it moves, so nothing here is
|
|
2698
|
+
* reachable by GET.
|
|
2699
|
+
*/
|
|
2700
|
+
async function handleCswapAutoAction(req, res) {
|
|
2701
|
+
const mod = await cswapAutoModule();
|
|
2702
|
+
const body = await readBody(req).catch(() => null);
|
|
2703
|
+
let parsed = null;
|
|
2704
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
2705
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
2706
|
+
|
|
2707
|
+
let result;
|
|
2708
|
+
switch (parsed.action) {
|
|
2709
|
+
case "enable":
|
|
2710
|
+
result = await mod.setAutoEnabled(parsed.enabled === true);
|
|
2711
|
+
break;
|
|
2712
|
+
case "setting":
|
|
2713
|
+
result = await mod.setCswapConfig(String(parsed.key ?? ""), parsed.value);
|
|
2714
|
+
break;
|
|
2715
|
+
case "account":
|
|
2716
|
+
result = await mod.setAccountEnabled(parsed.account, parsed.enabled === true);
|
|
2717
|
+
break;
|
|
2718
|
+
default:
|
|
2719
|
+
return send(res, 400, { ok: false, reason: "unknown_action" });
|
|
2720
|
+
}
|
|
2721
|
+
send(res, result.ok ? 200 : 400, result);
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
async function handleSoundHook(req, res) {
|
|
2725
|
+
const { soundHookStatus } = await import(
|
|
2726
|
+
pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
|
|
2727
|
+
);
|
|
2728
|
+
send(res, 200, await soundHookStatus());
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
async function handleSoundHookSet(req, res) {
|
|
2732
|
+
const { setSoundHook, restoreParkedSoundHooks } = await import(
|
|
2733
|
+
pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
|
|
2734
|
+
);
|
|
2735
|
+
const body = await readBody(req).catch(() => null);
|
|
2736
|
+
let parsed = null;
|
|
2737
|
+
try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
|
|
2738
|
+
if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
2739
|
+
if (parsed.action === "restore") return send(res, 200, await restoreParkedSoundHooks());
|
|
2740
|
+
send(res, 200, await setSoundHook(parsed.enabled === true));
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// The tree this deck was told to capture, "" when it captures the whole
|
|
2744
|
+
// machine. Set by startServer and never changed afterwards.
|
|
2745
|
+
//
|
|
2746
|
+
// The browser had no way to learn it: the launcher prints the scope on stdout
|
|
2747
|
+
// and the hook reads it out of the discovery file, but nothing put it in an
|
|
2748
|
+
// HTTP response — so the deck's own empty state guessed, and guessed wrong for
|
|
2749
|
+
// everyone who passed --workspace or --scope. Health is where it belongs: it
|
|
2750
|
+
// already answers "which deck am I talking to", and it is the one route the UI
|
|
2751
|
+
// can read before a single event has arrived.
|
|
2752
|
+
let _workspace = "";
|
|
2753
|
+
|
|
2754
|
+
// Which CLIs this deck is actually watching. Decided in bin/deck.js — from
|
|
2755
|
+
// whether each one is on the machine, and from --claude/--no-claude and
|
|
2756
|
+
// --codex/--no-codex — and passed in here, because the browser had no way to
|
|
2757
|
+
// learn it and so drew both sides of the UI on every machine.
|
|
2758
|
+
//
|
|
2759
|
+
// That is the whole of #402 and its mirror. A Codex-only machine got the
|
|
2760
|
+
// accounts panel open on first run, telling it to sign into a CLI it does not
|
|
2761
|
+
// have; a Claude-only machine permanently carried "Quota unavailable. / Run
|
|
2762
|
+
// codex login to authenticate." Same missing fact, two directions.
|
|
2763
|
+
//
|
|
2764
|
+
// Defaults are both true, which is what an older deck effectively reported by
|
|
2765
|
+
// saying nothing — and the browser reads a missing field as "could not say" and
|
|
2766
|
+
// shows both, so the two agree.
|
|
2767
|
+
let _providers = { claude: true, codex: true };
|
|
2768
|
+
|
|
2769
|
+
/**
|
|
2770
|
+
* The one spelling of `--workspace` everything downstream compares against.
|
|
2771
|
+
* Empty — including a value that is nothing but spaces — stays empty, which is
|
|
2772
|
+
* how every reader of it says "machine-wide".
|
|
2773
|
+
*
|
|
2774
|
+
* Called once, in bin/deck.js, where the flag arrives: that process's cwd is the
|
|
2775
|
+
* shell the user typed the command in, and it is the only process on either
|
|
2776
|
+
* capture path whose cwd is the one a relative `--workspace ./sub` was written
|
|
2777
|
+
* against. The raw string used to go straight into the discovery file, and
|
|
2778
|
+
* hook.js resolved it inside its own process — which the host CLI runs with the
|
|
2779
|
+
* AGENT's cwd — so `--workspace ./sub` scoped Claude sessions to a `sub`
|
|
2780
|
+
* directory under whatever the agent happened to be working in, a different
|
|
2781
|
+
* directory per agent and none of them the one asked for. The Codex path
|
|
2782
|
+
* resolved the same string in the server process and was right. Resolving here
|
|
2783
|
+
* makes both of them right, and for the same reason bin/deck.js already resolves
|
|
2784
|
+
* the events log before publishing it: what goes in the discovery file is read
|
|
2785
|
+
* by other processes that cannot reconstruct the context it was written in.
|
|
2786
|
+
*
|
|
2787
|
+
* Symlinks are resolved too, because a process's cwd — which is what both
|
|
2788
|
+
* providers report — comes from getcwd() and has none left in it. Without this,
|
|
2789
|
+
* `--workspace /tmp/proj` on a Mac is scoped to /tmp/proj while every session
|
|
2790
|
+
* inside it reports /private/tmp/proj, and the deck stays empty. A path that
|
|
2791
|
+
* does not exist yet keeps its resolved form rather than failing: scoping a deck
|
|
2792
|
+
* to a directory you are about to create is not an error.
|
|
2793
|
+
*/
|
|
2794
|
+
export function canonicalWorkspace(raw) {
|
|
2795
|
+
if (typeof raw !== "string" || raw.trim() === "") return "";
|
|
2796
|
+
const abs = resolve(raw);
|
|
2797
|
+
try { return realpathSync(abs); } catch { return abs; }
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function handleHealth(_req, res) {
|
|
2801
|
+
send(res, 200, {
|
|
2802
|
+
ok: true,
|
|
2803
|
+
name: "agent-dag",
|
|
2804
|
+
seq: nextSeq - 1,
|
|
2805
|
+
clients: sseClients.size,
|
|
2806
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
2807
|
+
workspace: _workspace,
|
|
2808
|
+
providers: _providers,
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
// A secret this process, and only this process, knows. It goes into the
|
|
2813
|
+
// discovery file bin/deck.js writes (mode 0600) and is never sent anywhere:
|
|
2814
|
+
// hook/hook.js reads it from that file and asks us to hash it against a nonce
|
|
2815
|
+
// before it will send us a single session payload.
|
|
2816
|
+
//
|
|
2817
|
+
// Fresh per start, deliberately. A discovery file that outlives its deck —
|
|
2818
|
+
// SIGKILL and power cuts both leave one behind — names a pid the OS is free to
|
|
2819
|
+
// hand to something else and a port that by then may belong to anything. The
|
|
2820
|
+
// pid probe below cannot tell that apart from a running deck, so the token is
|
|
2821
|
+
// what actually distinguishes us: the replacement process does not have it, and
|
|
2822
|
+
// neither does the next deck, so a stale file authenticates nothing.
|
|
2823
|
+
const HOOK_TOKEN = randomBytes(32).toString("hex");
|
|
2824
|
+
|
|
2825
|
+
/** The token this deck expects to be challenged on. Written by writeDiscovery. */
|
|
2826
|
+
export function hookToken() { return HOOK_TOKEN; }
|
|
2827
|
+
|
|
2828
|
+
/**
|
|
2829
|
+
* The proof of knowing `token`, for a nonce the challenger chose.
|
|
2830
|
+
*
|
|
2831
|
+
* hook/hook.js spells this out a second time — it is installed outside the
|
|
2832
|
+
* package and cannot import from here — and a test pins the two against each
|
|
2833
|
+
* other. Changing one without the other silently blinds the deck.
|
|
2834
|
+
*/
|
|
2835
|
+
export function challengeProof(token, nonce) {
|
|
2836
|
+
return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// GET /api/hook-challenge?nonce=… — answer a hook's challenge.
|
|
2840
|
+
//
|
|
2841
|
+
// The nonce is the caller's, so the answer proves knowledge of the token
|
|
2842
|
+
// without disclosing it, and proves it for this exchange only. Answering
|
|
2843
|
+
// freely is what the exchange requires: the hook is asking whether the process
|
|
2844
|
+
// on this port is the deck that wrote the discovery file, and it asks precisely
|
|
2845
|
+
// because it does not yet know — a deck that demanded credentials before
|
|
2846
|
+
// answering could not be told apart from a stranger that refuses.
|
|
2847
|
+
//
|
|
2848
|
+
// So this route is an oracle, and it must stay one. That makes its answer
|
|
2849
|
+
// useless as a credential FOR this server, and nothing here may ever accept it
|
|
2850
|
+
// as one: a caller who can GET this can obtain a valid proof for any nonce, so
|
|
2851
|
+
// a gate honouring proofs is a gate honouring anybody. See presentsDeckToken,
|
|
2852
|
+
// which takes the token itself and refuses the hashed form for this reason.
|
|
2853
|
+
//
|
|
2854
|
+
// What the free answer does NOT give away is the token: the response is a
|
|
2855
|
+
// one-way hash of it, and after the read gate above a rebound page cannot see
|
|
2856
|
+
// even that.
|
|
2857
|
+
function handleHookChallenge(_req, res, url) {
|
|
2858
|
+
const nonce = url.searchParams.get("nonce") ?? "";
|
|
2859
|
+
if (!nonce || nonce.length > 256) return send(res, 400, { error: "bad nonce" });
|
|
2860
|
+
send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
function isProcessAlive(pid) {
|
|
2864
|
+
try { process.kill(pid, 0); return true; }
|
|
2865
|
+
catch (e) { return e && e.code === "EPERM"; }
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
async function sweepStaleDiscovery() {
|
|
2869
|
+
// Same directory the installer writes and the hooks read — see claude-dir.mjs.
|
|
2870
|
+
const dir = join(claudeConfigDir(), "agent-dag");
|
|
2871
|
+
let files;
|
|
2872
|
+
try { files = await readdir(dir); } catch { return 0; }
|
|
2873
|
+
let removed = 0;
|
|
2874
|
+
for (const f of files) {
|
|
2875
|
+
if (!f.endsWith(".json")) continue;
|
|
2876
|
+
const p = join(dir, f);
|
|
2877
|
+
try {
|
|
2878
|
+
const d = JSON.parse(await readFile(p, "utf8"));
|
|
2879
|
+
if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
|
|
2880
|
+
await unlink(p).catch(() => {});
|
|
2881
|
+
removed++;
|
|
2882
|
+
}
|
|
2883
|
+
} catch { /* corrupt — leave it */ }
|
|
2884
|
+
}
|
|
2885
|
+
return removed;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
function randomPort(min, max) {
|
|
2889
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// Parse a request target into a URL, or null when it cannot be parsed.
|
|
2893
|
+
//
|
|
2894
|
+
// The base is the constant "http://localhost", never the Host header. Node's
|
|
2895
|
+
// HTTP parser hands the header through verbatim, so a client sending
|
|
2896
|
+
// `Host: bad host` used to produce `new URL("/", "http://bad host")`, which
|
|
2897
|
+
// throws ERR_INVALID_URL synchronously inside the request listener. Nothing
|
|
2898
|
+
// catches that — it becomes an uncaughtException, the worker exits 1 and the
|
|
2899
|
+
// supervisor tears the whole deck down for a single malformed request. Only
|
|
2900
|
+
// the path and the query are ever read from this URL, so the authority half
|
|
2901
|
+
// is free to be a constant — which is what every other new URL call site in
|
|
2902
|
+
// this file already does. Anything else unparseable answers 400 instead.
|
|
2903
|
+
export function requestUrl(rawUrl) {
|
|
2904
|
+
try { return new URL(rawUrl ?? "/", "http://localhost"); }
|
|
2905
|
+
catch { return null; }
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// Is this request allowed to be answered at all, whatever it asks for?
|
|
2909
|
+
//
|
|
2910
|
+
// The rebinding gate below was reached only by mutations, on the reasoning that
|
|
2911
|
+
// a cross-site page cannot read a loopback reply. That is true of a genuinely
|
|
2912
|
+
// cross-origin page and false of a rebound one: the browser resolved
|
|
2913
|
+
// attacker.example to 127.0.0.1 itself, so it calls the reply same-origin and
|
|
2914
|
+
// hands the body to the page. Every read was therefore open to exactly the
|
|
2915
|
+
// attack the mutation gate was written to stop — and the reads are where the
|
|
2916
|
+
// secrets are. GET /api/events is the whole ring buffer: prompt text, the Bash
|
|
2917
|
+
// command lines the agent ran, the paths and contents it wrote, the contents of
|
|
2918
|
+
// every file it read back. /api/claude-accounts names the accounts,
|
|
2919
|
+
// /api/claude-accounts/login carries a live OAuth authorize URL, /api/sound-hook
|
|
2920
|
+
// the user's own hook command lines, /api/health the absolute workspace path.
|
|
2921
|
+
//
|
|
2922
|
+
// So the Host check runs for every method now. What it asks is only the
|
|
2923
|
+
// rebinding question — did this request arrive addressed to a name that can
|
|
2924
|
+
// only ever be this machine — and it asks it of browser-shaped requests alone,
|
|
2925
|
+
// meaning anything carrying an Origin or fetch metadata. A client sending
|
|
2926
|
+
// neither is not a page and has no ambient authority to borrow: that is
|
|
2927
|
+
// hook/hook.js, a plain Node http.request from the user's own machine, and it
|
|
2928
|
+
// keeps reaching the deck under whatever name it used before.
|
|
2929
|
+
//
|
|
2930
|
+
// Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
|
|
2931
|
+
// applies. `cross-site` on a read is an ordinary top-level navigation — a link
|
|
2932
|
+
// to http://localhost:4317 clicked on any page — and the document it loads is
|
|
2933
|
+
// the deck's own UI on the deck's own origin, which is not an attack and used
|
|
2934
|
+
// to work. Rebinding does not need that test either: a rebound page's requests
|
|
2935
|
+
// report `same-origin`, and it is the Host that gives it away.
|
|
2936
|
+
export function isTrustedRead({ origin, host, secFetchSite } = {}) {
|
|
2937
|
+
const browserShaped = (typeof origin === "string" && origin !== "")
|
|
2938
|
+
|| (typeof secFetchSite === "string" && secFetchSite.trim() !== "");
|
|
2939
|
+
if (!browserShaped) return true;
|
|
2940
|
+
return isLoopbackHost(host);
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// Is this mutating request allowed to be acted on?
|
|
2944
|
+
//
|
|
2945
|
+
// The deck binds 127.0.0.1, which sounds private but is reachable from every
|
|
2946
|
+
// page the user's browser has open: a cross-site POST with a CORS-safelisted
|
|
2947
|
+
// `Content-Type: text/plain` fires no preflight, so any visited page could
|
|
2948
|
+
// remove a Claude account, import an attacker-crafted one, switch the live
|
|
2949
|
+
// account, flip auto-switching, start a global npm upgrade, restart the deck
|
|
2950
|
+
// or truncate the event log. None of those need to read the response, so the
|
|
2951
|
+
// same-origin policy alone never stopped them.
|
|
2952
|
+
//
|
|
2953
|
+
// Two headers decide it, and both are set by the browser itself — page script
|
|
2954
|
+
// cannot forge either, they are forbidden header names:
|
|
2955
|
+
//
|
|
2956
|
+
// Sec-Fetch-Site present on every modern-browser request. `same-origin`
|
|
2957
|
+
// (our own UI) and `none` (the user typed the URL) pass;
|
|
2958
|
+
// `cross-site` and `same-site` are exactly the attack and
|
|
2959
|
+
// are refused. Absent on older Safari, hence the second half.
|
|
2960
|
+
// Origin present on every browser POST, including same-origin ones.
|
|
2961
|
+
// It must name this very server: comparing it to the Host
|
|
2962
|
+
// header the browser filled in from the target URL. A page
|
|
2963
|
+
// on http://evil.com — or on http://localhost:8000, which is
|
|
2964
|
+
// just as cross-origin — cannot make the two agree. The
|
|
2965
|
+
// opaque `null` origin (sandboxed iframe, data: URL) fails
|
|
2966
|
+
// to parse and is refused with it.
|
|
2967
|
+
//
|
|
2968
|
+
// Agreement between the two is necessary but not sufficient, because both are
|
|
2969
|
+
// derived from the URL the page was served from and neither says a word about
|
|
2970
|
+
// the address the socket actually landed on — so the Host must also name a
|
|
2971
|
+
// loopback identity. See isLoopbackHost for the attack that gets through
|
|
2972
|
+
// without it.
|
|
2973
|
+
//
|
|
2974
|
+
// A request carrying neither header is not a browser request and is allowed
|
|
2975
|
+
// whatever Host it names: that is hook/hook.js, a plain Node http.request from
|
|
2976
|
+
// the user's own machine that sends no Origin at all, plus curl and the deck's
|
|
2977
|
+
// own tooling. Ambient browser authority is the whole threat here, and those
|
|
2978
|
+
// clients have none — a process that can POST here can already run anything as
|
|
2979
|
+
// the user, and nothing it sends is chosen by a page.
|
|
2980
|
+
export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
|
|
2981
|
+
const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
|
|
2982
|
+
if (site && site !== "same-origin" && site !== "none") return false;
|
|
2983
|
+
const hasOrigin = typeof origin === "string" && origin !== "";
|
|
2984
|
+
if (!hasOrigin && !site) return true;
|
|
2985
|
+
// Either header present means a browser sent this, so the rebinding gate
|
|
2986
|
+
// applies even to the shape that carries fetch metadata but no Origin. Every
|
|
2987
|
+
// request reaching this line is browser-shaped by the test just above, so
|
|
2988
|
+
// isTrustedRead here is exactly its isLoopbackHost half.
|
|
2989
|
+
if (!isTrustedRead({ origin, host, secFetchSite })) return false;
|
|
2990
|
+
return hasOrigin ? originMatchesHost(origin, host) : true;
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// Was this request addressed to a name that can only ever be this machine?
|
|
2994
|
+
//
|
|
2995
|
+
// Origin === Host alone is not a defence against DNS rebinding: the page is
|
|
2996
|
+
// served from http://attacker.example:4317, the attacker re-points that record
|
|
2997
|
+
// at 127.0.0.1, and the browser then sends Host: attacker.example:4317 with a
|
|
2998
|
+
// matching Origin and Sec-Fetch-Site: same-origin — every header self-consistent
|
|
2999
|
+
// and every one attacker-chosen, because fetch metadata comes from the origin
|
|
3000
|
+
// tuple (scheme, host, port), not from the resolved IP. The browser also treats
|
|
3001
|
+
// the reply as same-origin, so the page reads the body: that is the account
|
|
3002
|
+
// share envelope, with the OAuth token in it. A loopback literal has no DNS
|
|
3003
|
+
// record to re-point, and `localhost` is reserved to loopback, so requiring one
|
|
3004
|
+
// of them is what separates the deck's own UI from the rebound page.
|
|
3005
|
+
function isLoopbackHost(host) {
|
|
3006
|
+
if (typeof host !== "string") return false;
|
|
3007
|
+
const authority = host.trim().toLowerCase();
|
|
3008
|
+
// A real Host header is bare authority. Userinfo or a path would let
|
|
3009
|
+
// `evil.example@127.0.0.1` and `127.0.0.1/…` parse to a loopback hostname
|
|
3010
|
+
// while naming something else, so refuse them rather than reason about them.
|
|
3011
|
+
if (authority === "" || /[/\\?#@\s]/.test(authority)) return false;
|
|
3012
|
+
let name;
|
|
3013
|
+
try { name = new URL(`http://${authority}`).hostname; } catch { return false; }
|
|
3014
|
+
if (name === "localhost" || name === "[::1]") return true;
|
|
3015
|
+
// The whole 127.0.0.0/8 is this machine, not just .0.1 — a second deck parked
|
|
3016
|
+
// on 127.0.0.2 is as local as the first. URL only produces a dotted quad for
|
|
3017
|
+
// something it already validated as an IPv4 address, so shape is enough here.
|
|
3018
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// Does `origin` name the same host:port the request was addressed to? Ports are
|
|
3022
|
+
// part of an origin, so http://127.0.0.1:8000 is not http://127.0.0.1:4317 —
|
|
3023
|
+
// and the default port is spelled both ways depending on the client, so it is
|
|
3024
|
+
// normalised off both sides before comparing.
|
|
3025
|
+
function originMatchesHost(origin, host) {
|
|
3026
|
+
if (typeof host !== "string" || host === "") return false;
|
|
3027
|
+
let parsed;
|
|
3028
|
+
try { parsed = new URL(origin); } catch { return false; }
|
|
3029
|
+
const dropDefaultPort = (h, scheme) =>
|
|
3030
|
+
h.replace(scheme === "https:" ? /:443$/ : /:80$/, "");
|
|
3031
|
+
const fromOrigin = dropDefaultPort(parsed.host.toLowerCase(), parsed.protocol);
|
|
3032
|
+
const fromHost = dropDefaultPort(host.trim().toLowerCase(), parsed.protocol);
|
|
3033
|
+
return fromOrigin !== "" && fromOrigin === fromHost;
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// Mutating routes that ask nothing of the caller beyond the gates above.
|
|
3037
|
+
//
|
|
3038
|
+
// Only the hook's ingest, and only because hook/hook.js is installed OUTSIDE
|
|
3039
|
+
// this package — it lives in the user's ~/.claude and is loaded by whatever
|
|
3040
|
+
// Claude Code session is already running. Requiring a credential here would
|
|
3041
|
+
// stop every event from every session whose hook predates this change, on a
|
|
3042
|
+
// machine where nothing is obviously broken and nothing says why, until each
|
|
3043
|
+
// one is reinstalled. It also carries no credential and destroys nothing: the
|
|
3044
|
+
// worst a caller does with it is draw a session on the canvas that is not
|
|
3045
|
+
// there. See the handshake in hook/hook.js for the authentication that does
|
|
3046
|
+
// run on this path, which is the deck proving itself to the hook.
|
|
3047
|
+
const OPEN_MUTATIONS = new Set(["/api/event"]);
|
|
3048
|
+
|
|
3049
|
+
// Constant-time comparison of two secrets, and a length test that is not.
|
|
3050
|
+
// Lengths differ freely in public — a mismatched one only says "not this
|
|
3051
|
+
// token" — but timingSafeEqual throws rather than answering false when they do.
|
|
3052
|
+
function secretEquals(given, expected) {
|
|
3053
|
+
const a = Buffer.from(String(given ?? ""), "utf8");
|
|
3054
|
+
const b = Buffer.from(String(expected ?? ""), "utf8");
|
|
3055
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
3056
|
+
return timingSafeEqual(a, b);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// Does this request carry the deck's own token?
|
|
3060
|
+
//
|
|
3061
|
+
// One spelling, `x-ccdeck-token`, carrying the token itself. That is all a
|
|
3062
|
+
// local process needs, because the only way it can hold the token is to have
|
|
3063
|
+
// read it out of a file only it can open.
|
|
3064
|
+
//
|
|
3065
|
+
// The token is HOOK_TOKEN, fresh per deck and written to the discovery file at
|
|
3066
|
+
// mode 0600. That mode is the whole of the access control: the answer to "who
|
|
3067
|
+
// may do this" is "whoever can read that file", which is the user this deck
|
|
3068
|
+
// runs as and nobody else. On Windows the chmod is a best-effort no-op, so
|
|
3069
|
+
// there the file's protection is the per-user ACL on the profile directory it
|
|
3070
|
+
// sits in rather than a mode bit; the token still works identically.
|
|
3071
|
+
//
|
|
3072
|
+
// The hashed challengeProof form is deliberately NOT accepted here, and this
|
|
3073
|
+
// is the paragraph that must be read before anyone adds it. GET
|
|
3074
|
+
// /api/hook-challenge?nonce=… answers challengeProof(HOOK_TOKEN, nonce) to any
|
|
3075
|
+
// caller for any nonce, by design — so accepting a proof as a credential means
|
|
3076
|
+
// the server hands out its own credentials on request, and two unauthenticated
|
|
3077
|
+
// GETs are a complete bypass of everything below. This gate shipped that way
|
|
3078
|
+
// for one commit; the regression test in csrf-origin.test.ts pins it shut.
|
|
3079
|
+
//
|
|
3080
|
+
// The premise that makes the oracle safe is the one this gate rejects, which is
|
|
3081
|
+
// exactly why the two cannot share a secret: the challenge is the deck proving
|
|
3082
|
+
// itself TO the hook, not the hook proving itself to the deck. hook/hook.js
|
|
3083
|
+
// already holds the token — it read the same 0600 file — and challenges the
|
|
3084
|
+
// port to find out whether the process listening there is really the deck that
|
|
3085
|
+
// wrote it, or something else that inherited the port number from a stale
|
|
3086
|
+
// discovery file. Nothing travels in the other direction, so no caller ever
|
|
3087
|
+
// needed the hashed form as a way IN, and a client entitled to mutate holds the
|
|
3088
|
+
// token itself anyway.
|
|
3089
|
+
function presentsDeckToken(headers = {}) {
|
|
3090
|
+
const raw = headers["x-ccdeck-token"];
|
|
3091
|
+
return typeof raw === "string" && secretEquals(raw.trim(), HOOK_TOKEN);
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Is this the deck's own page in the user's browser?
|
|
3095
|
+
//
|
|
3096
|
+
// A strict subset of isTrustedMutation, which every request here has already
|
|
3097
|
+
// passed: an Origin must actually be present — a browser sends one on every
|
|
3098
|
+
// POST, including a same-origin one — it must name this very server, the Host
|
|
3099
|
+
// must be a loopback identity, and any fetch metadata must say `same-origin`.
|
|
3100
|
+
// `none` is excluded on purpose: that is a top-level navigation the user typed
|
|
3101
|
+
// or a form submitted from one, never the UI's own fetch.
|
|
3102
|
+
function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
|
|
3103
|
+
const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
|
|
3104
|
+
if (site !== "" && site !== "same-origin") return false;
|
|
3105
|
+
if (typeof origin !== "string" || origin === "") return false;
|
|
3106
|
+
if (!isLoopbackHost(host)) return false;
|
|
3107
|
+
return originMatchesHost(origin, host);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// May this request change something?
|
|
3111
|
+
//
|
|
3112
|
+
// Everything above this line is about the browser: whether a page chose the
|
|
3113
|
+
// request, and whether it was addressed to this machine. None of it asks who
|
|
3114
|
+
// the caller is, and for a client that is not a browser at all nothing did —
|
|
3115
|
+
// a request carrying no Origin and no fetch metadata was waved through on the
|
|
3116
|
+
// reasoning that a process able to POST here can already run anything as the
|
|
3117
|
+
// user. That holds on a single-user laptop and fails twice elsewhere. Loopback
|
|
3118
|
+
// is not scoped to a UID, so on a shared box or a multi-tenant container every
|
|
3119
|
+
// other account on the machine can reach this port; and a sandboxed subprocess
|
|
3120
|
+
// denied the credential store but allowed loopback egress — the ordinary shape
|
|
3121
|
+
// of an agent's Bash sandbox — reaches the same credentials through the API.
|
|
3122
|
+
// `curl -XPOST localhost:4317/api/claude-accounts/admin -d '{"action":"share"}'`
|
|
3123
|
+
// answered with the account's live OAuth refresh token in the clear, and the
|
|
3124
|
+
// same request reached account remove and import, the live account switch, a
|
|
3125
|
+
// global `npm i -g`, the restart and the event log.
|
|
3126
|
+
//
|
|
3127
|
+
// So a mutation must now be either of two things:
|
|
3128
|
+
//
|
|
3129
|
+
// - the user's own browser tab, recognised by the headers a page cannot
|
|
3130
|
+
// forge and the deck's own loopback address (isDeckUiRequest), or
|
|
3131
|
+
// - a client holding the deck's token, which it can only have read from the
|
|
3132
|
+
// 0600 discovery file (presentsDeckToken).
|
|
3133
|
+
//
|
|
3134
|
+
// What this does NOT do, stated plainly so nobody mistakes it for more: the
|
|
3135
|
+
// browser clause rests on headers that page script cannot set but any local
|
|
3136
|
+
// program can. A local attacker who adds `Origin` and `Sec-Fetch-Site` to the
|
|
3137
|
+
// request is back through. That is not a gap left by laziness — there is no
|
|
3138
|
+
// fix for it here. Authenticating the tab would mean giving the tab a secret,
|
|
3139
|
+
// and every channel from this process to a browser on the same machine is
|
|
3140
|
+
// readable by any other process on that machine: a token in the served
|
|
3141
|
+
// index.html is read by the same `curl http://localhost:4317/` that the gate
|
|
3142
|
+
// is meant to stop, and a token in the URL the deck opens is read out of the
|
|
3143
|
+
// browser's argv by `ps`. What is closed is the free pass — a request that
|
|
3144
|
+
// presents nothing at all no longer changes anything — and what is opened is
|
|
3145
|
+
// the honest door, so a script of the user's own authenticates by reading the
|
|
3146
|
+
// token instead of impersonating a page.
|
|
3147
|
+
function isAuthorizedMutation(req) {
|
|
3148
|
+
const headers = req?.headers ?? {};
|
|
3149
|
+
if (presentsDeckToken(headers)) return true;
|
|
3150
|
+
return isDeckUiRequest({
|
|
3151
|
+
origin: headers.origin,
|
|
3152
|
+
host: headers.host,
|
|
3153
|
+
secFetchSite: headers["sec-fetch-site"],
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// A request handler rejected. Two audiences, two different amounts of detail:
|
|
3158
|
+
// the operator, who needs the whole error — stack included — to find the bug,
|
|
3159
|
+
// and the HTTP client, which needs to know only that the request failed.
|
|
3160
|
+
//
|
|
3161
|
+
// They used to get the same string, on the theory that this server binds
|
|
3162
|
+
// 127.0.0.1 and its only client is the user's own tab. A DNS-rebound page
|
|
3163
|
+
// reaches a loopback server as same-origin and can read the body, and the
|
|
3164
|
+
// errors that land here carry absolute paths out of the user's home directory
|
|
3165
|
+
// — a failed rename of ~/.claude/settings.json, an ENOENT from an import. So
|
|
3166
|
+
// stderr keeps every byte and the response body carries none of it; nothing is
|
|
3167
|
+
// swallowed, it just stops travelling over the wire.
|
|
3168
|
+
export function sendInternalError(res, err, log = console.error) {
|
|
3169
|
+
log(`${PRODUCT}: request handler failed:`, err);
|
|
3170
|
+
if (!res.headersSent) send(res, 500, { error: "internal error" });
|
|
3171
|
+
else res.end();
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
// One attempt, leaving the server with no more listeners on it than it started
|
|
3175
|
+
// with. `server.listen(port, host, cb)` registers `cb` for a 'listening' event
|
|
3176
|
+
// that a failed bind never emits, so the previous shape left one behind per
|
|
3177
|
+
// attempt — eleven candidates printed Node's "MaxListenersExceededWarning:
|
|
3178
|
+
// 11 listening listeners added to [Server]" into the middle of a boot that was
|
|
3179
|
+
// already going wrong. Both sides are removed by whichever fires first.
|
|
3180
|
+
async function tryListen(server, port, host) {
|
|
3181
|
+
return new Promise((res, rej) => {
|
|
3182
|
+
const onListening = () => { server.removeListener("error", onError); res(); };
|
|
3183
|
+
const onError = (err) => { server.removeListener("listening", onListening); rej(err); };
|
|
3184
|
+
server.once("error", onError);
|
|
3185
|
+
server.once("listening", onListening);
|
|
3186
|
+
server.listen(port, host);
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
/**
|
|
3191
|
+
* Whether another PORT could fix this failed `listen`.
|
|
3192
|
+
*
|
|
3193
|
+
* startServer builds ten random fallback candidates and they exist for exactly
|
|
3194
|
+
* one situation: "this port is unavailable". Until #552 only EADDRINUSE reached
|
|
3195
|
+
* them, which is the POSIX spelling of that answer and not the only one.
|
|
3196
|
+
*
|
|
3197
|
+
* WINDOWS. `winnat` hands out contiguous TCP blocks to Hyper-V, WSL2 and Docker
|
|
3198
|
+
* Desktop, and a bind INSIDE one of those reserved exclusion ranges — the ones
|
|
3199
|
+
* `netsh interface ipv4 show excludedportrange protocol=tcp` prints — is refused
|
|
3200
|
+
* with WSAEACCES, which libuv reports as EACCES. Any machine with containers or
|
|
3201
|
+
* WSL on it can therefore have 4317 blocked without a single socket being open
|
|
3202
|
+
* on it. The loop rethrew on the first candidate, bin/deck.js printed
|
|
3203
|
+
* `server failed: listen EACCES: permission denied 127.0.0.1:4317`, and the deck
|
|
3204
|
+
* exited 1 with the fallback range untouched.
|
|
3205
|
+
*
|
|
3206
|
+
* On POSIX the same code is what a bind below 1024 gets without privilege.
|
|
3207
|
+
* Retrying is right there too: 4317 and the whole fallback range are above 1024,
|
|
3208
|
+
* so a random candidate is a port the user can actually have — and the deck
|
|
3209
|
+
* coming up on 4322 beats it refusing to come up at all, which is already how
|
|
3210
|
+
* EADDRINUSE on a privileged port behaves today.
|
|
3211
|
+
*
|
|
3212
|
+
* The other direction is the half that keeps this honest. EADDRNOTAVAIL,
|
|
3213
|
+
* ENOTFOUND, EAI_AGAIN and EAFNOSUPPORT are about the HOST, not the port: the
|
|
3214
|
+
* address does not exist on this machine or does not resolve, and no candidate
|
|
3215
|
+
* can help. Walking eleven of them only delays the one sentence that would have
|
|
3216
|
+
* explained it, so anything not named here stops the loop.
|
|
3217
|
+
*/
|
|
3218
|
+
export const portRetryable = (err) =>
|
|
3219
|
+
Boolean(err) && (err.code === "EADDRINUSE" || err.code === "EACCES");
|
|
3220
|
+
|
|
3221
|
+
/**
|
|
3222
|
+
* The sentence that goes beside a listen errno, or "" when the errno says it
|
|
3223
|
+
* all.
|
|
3224
|
+
*
|
|
3225
|
+
* Pure, and the platform is a parameter, for the reason every Windows answer in
|
|
3226
|
+
* this repo is written that way: the branch that matters most is the one the
|
|
3227
|
+
* author cannot run. A raw `listen EACCES: permission denied 127.0.0.1:4317` is
|
|
3228
|
+
* true and useless — the thing the user needs is the name of the command that
|
|
3229
|
+
* lists the ranges their machine has reserved.
|
|
3230
|
+
*/
|
|
3231
|
+
export function listenHint(code, { host = "", port = 0, platform = process.platform } = {}) {
|
|
3232
|
+
if (code === "EACCES") {
|
|
3233
|
+
if (platform === "win32") {
|
|
3234
|
+
return "a reserved port range is the usual cause on Windows: Hyper-V, WSL2 and Docker Desktop have winnat hold contiguous TCP blocks. Run `netsh interface ipv4 show excludedportrange protocol=tcp` to see them, then pass --port with a number outside every range";
|
|
3235
|
+
}
|
|
3236
|
+
if (port > 0 && port < 1024) return "ports below 1024 need root — pass --port with a number above 1024";
|
|
3237
|
+
return "the OS refused the bind; a sandbox or a security policy is the usual cause";
|
|
3238
|
+
}
|
|
3239
|
+
if (code === "EADDRNOTAVAIL") {
|
|
3240
|
+
return `no interface on this machine holds ${host || "that address"}, so no other port can help — pass --host with an address it does hold, or 127.0.0.1 for local only`;
|
|
3241
|
+
}
|
|
3242
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
3243
|
+
return `${host || "that host"} does not resolve, so no other port can help — pass --host with an address rather than a name`;
|
|
3244
|
+
}
|
|
3245
|
+
return "";
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
/**
|
|
3249
|
+
* The error startServer throws, with the hint already in it.
|
|
3250
|
+
*
|
|
3251
|
+
* `exhausted` is the difference between "every candidate was refused" and "this
|
|
3252
|
+
* one was refused for a reason more candidates cannot fix". The exhausted
|
|
3253
|
+
* message keeps its opening words — bin/deck.js prints them and
|
|
3254
|
+
* boot-listen-before-report.test.ts reads them — and now names the LAST errno
|
|
3255
|
+
* rather than asserting EADDRINUSE, which was a lie the moment EACCES could
|
|
3256
|
+
* reach the end of the loop.
|
|
3257
|
+
*/
|
|
3258
|
+
export function listenFailure(err, { host = "", port = 0, platform = process.platform, exhausted = false } = {}) {
|
|
3259
|
+
const code = err?.code;
|
|
3260
|
+
const head = exhausted
|
|
3261
|
+
? `all ports tried — none available (last: ${code ?? "unknown"} on ${host}:${port})`
|
|
3262
|
+
: String(err?.message ?? err ?? "listen failed");
|
|
3263
|
+
const hint = listenHint(code, { host, port, platform });
|
|
3264
|
+
const out = new Error(hint ? `${head} — ${hint}` : head);
|
|
3265
|
+
if (code !== undefined) out.code = code;
|
|
3266
|
+
if (err) out.cause = err;
|
|
3267
|
+
return out;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// Set from startServer's options. The server cannot restart itself — the
|
|
3271
|
+
// process lifecycle belongs to the supervisor in bin/agent-dag.js, which is the
|
|
3272
|
+
// only thing that can bring a replacement up on the same port without racing
|
|
3273
|
+
// the dying listener. Absent (running the module directly, or under an older
|
|
3274
|
+
// launcher), /api/restart answers 501 and the UI hides the control rather than
|
|
3275
|
+
// offering a button that does nothing.
|
|
3276
|
+
let _onRestart = null;
|
|
3277
|
+
// A restart is in flight. Several browser tabs watching the same deck will each
|
|
3278
|
+
// ask; the second ask must not re-enter the shutdown.
|
|
3279
|
+
let _restarting = false;
|
|
3280
|
+
// Whether the launcher has finished booting — see markDeckReady. False for the
|
|
3281
|
+
// whole window between this listener accepting its first connection and
|
|
3282
|
+
// bin/deck.js reaching the end of its startup, which is a window /api/restart
|
|
3283
|
+
// is reachable in and cannot answer for on its own.
|
|
3284
|
+
let _deckReady = false;
|
|
3285
|
+
|
|
3286
|
+
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null } = {}) {
|
|
3287
|
+
_onRestart = typeof onRestart === "function" ? onRestart : null;
|
|
3288
|
+
_canRestart = _onRestart != null && persist != null;
|
|
3289
|
+
// A new listener is a new boot, whatever a previous one had got as far as
|
|
3290
|
+
// reporting. Nothing but bin/deck.js ever sets this, and it does so once, at
|
|
3291
|
+
// the end of the startup that begins with this call.
|
|
3292
|
+
_deckReady = false;
|
|
3293
|
+
_workspace = typeof workspace === "string" ? workspace : "";
|
|
3294
|
+
// `!== false` rather than a cast: a caller that omits the field means "yes",
|
|
3295
|
+
// which is how every embedder that predates this option keeps working.
|
|
3296
|
+
_providers = { claude: claude !== false, codex: codex !== false };
|
|
3297
|
+
const removed = await sweepStaleDiscovery();
|
|
3298
|
+
if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
|
|
3299
|
+
if (persist) {
|
|
3300
|
+
persistPath = resolve(persist);
|
|
3301
|
+
try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
|
|
3302
|
+
const replayed = await replayLog(persistPath);
|
|
3303
|
+
if (replayed > 0) {
|
|
3304
|
+
// Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
|
|
3305
|
+
// already. Just keep the buffer + seq counter primed.
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
// The async handlers below are dispatched as floating promises. Node's
|
|
3309
|
+
// default for an unhandled rejection is to kill the process, which would
|
|
3310
|
+
// take the whole deck down — SSE stream, hook ingest and all — because one
|
|
3311
|
+
// background quota poll hit a network error. Answer the request instead.
|
|
3312
|
+
const guard = (p, res) => Promise.resolve(p).catch(err => sendInternalError(res, err));
|
|
3313
|
+
|
|
3314
|
+
const server = createServer((req, res) => {
|
|
3315
|
+
const url = requestUrl(req.url);
|
|
3316
|
+
// Unparseable request target. Nothing below can route it, and throwing here
|
|
3317
|
+
// would be an uncaughtException inside the listener — i.e. the whole deck.
|
|
3318
|
+
if (!url) return send(res, 400, { error: "bad request target" });
|
|
3319
|
+
|
|
3320
|
+
// Three gates in front of the whole table, so every route is covered and
|
|
3321
|
+
// any route added later is covered too, without the author having to
|
|
3322
|
+
// remember. They ask three different questions and they run in the order
|
|
3323
|
+
// that answers the cheapest first.
|
|
3324
|
+
|
|
3325
|
+
// Was this addressed to this machine? Every method, because a rebound page
|
|
3326
|
+
// reads a reply as readily as it writes a request. See isTrustedRead.
|
|
3327
|
+
if (!isTrustedRead({
|
|
3328
|
+
origin: req.headers.origin,
|
|
3329
|
+
host: req.headers.host,
|
|
3330
|
+
secFetchSite: req.headers["sec-fetch-site"],
|
|
3331
|
+
})) {
|
|
3332
|
+
return send(res, 403, { error: "cross-site request blocked" });
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
// Did a page choose this? Mutations only — the same-site read this refuses
|
|
3336
|
+
// is an ordinary navigation. See isTrustedMutation.
|
|
3337
|
+
if (req.method !== "GET" && req.method !== "HEAD" && !isTrustedMutation({
|
|
3338
|
+
origin: req.headers.origin,
|
|
3339
|
+
host: req.headers.host,
|
|
3340
|
+
secFetchSite: req.headers["sec-fetch-site"],
|
|
3341
|
+
})) {
|
|
3342
|
+
return send(res, 403, { error: "cross-site request blocked" });
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
// And who is asking? Refusing by default is the point: a mutating route
|
|
3346
|
+
// added later is protected until someone deliberately lists it as open,
|
|
3347
|
+
// which is the direction this has to fail in. See isAuthorizedMutation.
|
|
3348
|
+
if (req.method !== "GET" && req.method !== "HEAD"
|
|
3349
|
+
&& !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req)) {
|
|
3350
|
+
return send(res, 401, { error: "unauthenticated" });
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
// `?persist=0` — another deck was elected to write this event to the log
|
|
3354
|
+
// the two of them share. Absent, this deck writes it.
|
|
3355
|
+
if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
|
|
3356
|
+
if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
|
|
3357
|
+
if (req.method === "GET" && url.pathname === "/api/hook-challenge") return handleHookChallenge(req, res, url);
|
|
3358
|
+
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
3359
|
+
if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
|
|
3360
|
+
if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
|
|
3361
|
+
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
3362
|
+
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
3363
|
+
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
3364
|
+
// Machine state, not session state: sampled on the server's own timer and
|
|
3365
|
+
// deliberately kept out of the event stream. See src/server/system-metrics.mjs.
|
|
3366
|
+
if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
|
|
3367
|
+
// On demand only — the process list costs a subprocess on every platform,
|
|
3368
|
+
// so it is fetched while the detail panel is open and never on the timer.
|
|
3369
|
+
if (req.method === "GET" && url.pathname === "/api/system/processes") {
|
|
3370
|
+
return guard(readProcesses().then(procs => send(res, 200, { ok: true, procs })), res);
|
|
3371
|
+
}
|
|
3372
|
+
if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
|
|
3373
|
+
if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
|
|
3374
|
+
if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
|
|
3375
|
+
if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
|
|
3376
|
+
if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
|
|
3377
|
+
if (req.method === "POST" && url.pathname === "/api/claude-accounts/admin") return guard(handleClaudeAccountAdmin(req, res), res);
|
|
3378
|
+
if (req.method === "GET" && url.pathname === "/api/sound-hook") return guard(handleSoundHook(req, res), res);
|
|
3379
|
+
if (req.method === "POST" && url.pathname === "/api/sound-hook") return guard(handleSoundHookSet(req, res), res);
|
|
3380
|
+
if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
|
|
3381
|
+
if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
|
|
3382
|
+
|
|
3383
|
+
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
3384
|
+
return send(res, 200, eventsSince(url.searchParams.get("since") ?? 0));
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
// POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
|
|
3388
|
+
if (req.method === "POST" && url.pathname === "/api/clear") {
|
|
3389
|
+
events.length = 0;
|
|
3390
|
+
if (persistPath) truncate(persistPath, 0).catch(() => {});
|
|
3391
|
+
// Drop the caches that gate an emit on "has this changed", because the
|
|
3392
|
+
// client is about to forget what they are comparing against: __clear makes
|
|
3393
|
+
// the reducer return a fresh state, so every session's name and every
|
|
3394
|
+
// subagent's model label go with it. maybeResolveSessionName then computes
|
|
3395
|
+
// the same signature, takes its early return, and emits nothing — so the
|
|
3396
|
+
// card falls back to cwd/prompt for the rest of that session while the
|
|
3397
|
+
// server is sitting on the name.
|
|
3398
|
+
//
|
|
3399
|
+
// The root model survives without help because pushEvent stamps
|
|
3400
|
+
// `raw.model` on every payload; there is no equivalent stamp for the name
|
|
3401
|
+
// or for a subagent's model, which is why those two are listed and the
|
|
3402
|
+
// rest of the per-session state is not.
|
|
3403
|
+
//
|
|
3404
|
+
// The rule, for the next cache that gates an emit: anything answering
|
|
3405
|
+
// "has this changed" has to appear in BOTH places that mean the client no
|
|
3406
|
+
// longer has it — here, and in forgetSession.
|
|
3407
|
+
nameBySession.clear();
|
|
3408
|
+
modelBySession.clear();
|
|
3409
|
+
// The read stamps go with them. Clearing only the signatures would leave
|
|
3410
|
+
// the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
|
|
3411
|
+
// would not be re-read at all and the name would stay missing until the
|
|
3412
|
+
// throttle expired — a clear followed by a keystroke is exactly when a
|
|
3413
|
+
// user is watching.
|
|
3414
|
+
lastNameReadAt.clear();
|
|
3415
|
+
modelLastReadAt.clear();
|
|
3416
|
+
pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
|
|
3417
|
+
return send(res, 200, { ok: true });
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
if (req.method === "GET") return serveStatic(req, res, url);
|
|
3421
|
+
send(res, 405, { error: "method not allowed" });
|
|
3422
|
+
});
|
|
3423
|
+
|
|
3424
|
+
// Try requested port first, then up to 10 random ports from portRange.
|
|
3425
|
+
const candidates = [port];
|
|
3426
|
+
for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
|
|
3427
|
+
|
|
3428
|
+
// The errno the exhaustion message ends up naming, and the port it happened
|
|
3429
|
+
// on. Kept because the last candidate's reason is the only one still worth
|
|
3430
|
+
// saying by then — the nine before it were random ports nobody asked for.
|
|
3431
|
+
let lastErr = null;
|
|
3432
|
+
let lastPort = port;
|
|
3433
|
+
for (const candidate of candidates) {
|
|
3434
|
+
try {
|
|
3435
|
+
await tryListen(server, candidate, host);
|
|
3436
|
+
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
3437
|
+
if (codex) startCodexWatcher(workspace);
|
|
3438
|
+
// Both timers are unref'd, so this never holds the process open.
|
|
3439
|
+
startSystemMetrics();
|
|
3440
|
+
// Auto-switch resumes only if the user previously turned it on; the
|
|
3441
|
+
// module reads its own persisted flag and does nothing otherwise.
|
|
3442
|
+
cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
|
|
3443
|
+
return server;
|
|
3444
|
+
} catch (err) {
|
|
3445
|
+
lastErr = err;
|
|
3446
|
+
lastPort = candidate;
|
|
3447
|
+
// "This port is unavailable" — which Windows spells EACCES for a port
|
|
3448
|
+
// inside a reserved exclusion range. See portRetryable.
|
|
3449
|
+
if (portRetryable(err)) continue;
|
|
3450
|
+
// Anything else is about the host or the socket, and the next candidate
|
|
3451
|
+
// would fail identically. Say why instead of trying ten more times.
|
|
3452
|
+
throw listenFailure(err, { host, port: candidate });
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
throw listenFailure(lastErr, { host, port: lastPort, exhausted: true });
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
// Allow running this file directly for dev (`npm run dev:server`). The port
|
|
3459
|
+
// variable is the CLI's, deliberately: this block spent two renames reading a
|
|
3460
|
+
// name from the project's first identity that no README ever documented, so
|
|
3461
|
+
// the one variable people know worked everywhere except here.
|
|
3462
|
+
//
|
|
3463
|
+
// argv[1] is a string only when node was handed a script path. `node -e`,
|
|
3464
|
+
// `--input-type=module` on stdin and a worker started from eval source all
|
|
3465
|
+
// leave it undefined, and pathToFileURL(undefined) throws instead of answering
|
|
3466
|
+
// false — which fails the whole import, in the one file whose job is to export
|
|
3467
|
+
// startServer. Guard the argument, not the comparison (#481).
|
|
3468
|
+
const entryPath = process.argv[1];
|
|
3469
|
+
if (entryPath && import.meta.url === pathToFileURL(entryPath).href) {
|
|
3470
|
+
const port = Number(process.env.AGENT_DAG_PORT ?? 4317);
|
|
3471
|
+
startServer({ port }).then(s => {
|
|
3472
|
+
const addr = s.address();
|
|
3473
|
+
const p = typeof addr === "object" && addr ? addr.port : port;
|
|
3474
|
+
console.log(`${PRODUCT} server: http://127.0.0.1:${p}`);
|
|
3475
|
+
}).catch(e => {
|
|
3476
|
+
console.error(`${PRODUCT} server failed:`, e.message);
|
|
3477
|
+
process.exit(1);
|
|
3478
|
+
});
|
|
3479
|
+
}
|