agents-deck 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/bin/agent-dag.js +227 -0
- package/dist/web/assets/index-DGqLVo1U.css +1 -0
- package/dist/web/assets/index-uANyTzBq.js +62 -0
- package/dist/web/index.html +14 -0
- package/hook/hook.js +110 -0
- package/package.json +70 -0
- package/src/server/index.mjs +1109 -0
- package/src/server/installer.mjs +185 -0
|
@@ -0,0 +1,1109 @@
|
|
|
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, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
|
|
5
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { extname, join, resolve, dirname as pdirname, sep } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PKG_ROOT = resolve(__dirname, "..", "..");
|
|
14
|
+
const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
|
|
15
|
+
|
|
16
|
+
const MIME = {
|
|
17
|
+
".html": "text/html; charset=utf-8",
|
|
18
|
+
".js": "application/javascript; charset=utf-8",
|
|
19
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
20
|
+
".css": "text/css; charset=utf-8",
|
|
21
|
+
".json": "application/json; charset=utf-8",
|
|
22
|
+
".svg": "image/svg+xml",
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".woff2": "font/woff2",
|
|
26
|
+
".map": "application/json",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
|
|
30
|
+
const events = []; // ring buffer
|
|
31
|
+
let nextSeq = 1;
|
|
32
|
+
const sseClients = new Set(); // res handles
|
|
33
|
+
|
|
34
|
+
let persistPath = null; // absolute path to events.jsonl, or null
|
|
35
|
+
|
|
36
|
+
// ─── Persistence rotation ─────────────────────────────────────────────────
|
|
37
|
+
// 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
|
|
38
|
+
// across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
|
|
39
|
+
// the previous file to .1 and starting fresh. Last-event-id replay still
|
|
40
|
+
// covers the in-memory ring buffer of MAX_BUFFER events.
|
|
41
|
+
const ROTATE_AT_BYTES = 50 * 1024 * 1024;
|
|
42
|
+
let lastRotateCheckAt = 0;
|
|
43
|
+
let rotateInProgress = false;
|
|
44
|
+
async function maybeRotatePersistFile() {
|
|
45
|
+
if (!persistPath) return;
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
// Throttle disk-stat checks to once per 30s.
|
|
48
|
+
if (now - lastRotateCheckAt < 30_000) return;
|
|
49
|
+
lastRotateCheckAt = now;
|
|
50
|
+
if (rotateInProgress) return;
|
|
51
|
+
rotateInProgress = true;
|
|
52
|
+
try {
|
|
53
|
+
const s = await stat(persistPath).catch(() => null);
|
|
54
|
+
if (!s || s.size < ROTATE_AT_BYTES) return;
|
|
55
|
+
// Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
|
|
56
|
+
const oldPath = persistPath + ".1";
|
|
57
|
+
try { await unlink(oldPath); } catch {}
|
|
58
|
+
const { rename } = await import("node:fs/promises");
|
|
59
|
+
await rename(persistPath, oldPath);
|
|
60
|
+
console.log(`agents-deck: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error("agents-deck: persist rotation failed:", err && err.message ? err.message : err);
|
|
63
|
+
} finally {
|
|
64
|
+
rotateInProgress = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Model enrichment ────────────────────────────────────────────────────
|
|
69
|
+
// CC's hook payloads never carry the `model` field — but every hook
|
|
70
|
+
// references a `transcript_path` JSONL that contains lines like
|
|
71
|
+
// `"model":"claude-opus-4-7"`. We read the tail of that file once per
|
|
72
|
+
// session, cache the result, and (a) inject `model` into subsequent
|
|
73
|
+
// payloads for that session before broadcasting, (b) emit a synthetic
|
|
74
|
+
// `ModelObserved` event so the client backfills agents created before
|
|
75
|
+
// the model was resolved.
|
|
76
|
+
const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
|
|
77
|
+
const pendingTranscriptReads = new Set(); // sessionId currently being read
|
|
78
|
+
const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
|
|
79
|
+
const MODEL_READ_THROTTLE_MS = 2500;
|
|
80
|
+
|
|
81
|
+
/** Read the main session JSONL. Returns the root model and any
|
|
82
|
+
* legacy-schema subagent models (older CC versions kept subagent blocks
|
|
83
|
+
* inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
|
|
84
|
+
* store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
|
|
85
|
+
* handled by `readSubagentModelsFromDir` below. */
|
|
86
|
+
async function readModelFromTranscript(path) {
|
|
87
|
+
try {
|
|
88
|
+
const s = await stat(path);
|
|
89
|
+
if (s.size === 0) return null;
|
|
90
|
+
const fh = await open(path, "r");
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
const buf = Buffer.alloc(s.size);
|
|
94
|
+
await fh.read(buf, 0, s.size, 0);
|
|
95
|
+
text = buf.toString("utf8");
|
|
96
|
+
} finally {
|
|
97
|
+
await fh.close();
|
|
98
|
+
}
|
|
99
|
+
let rootModel = null;
|
|
100
|
+
const subagentModels = {};
|
|
101
|
+
let anyModelSeen = null;
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
if (!line) continue;
|
|
104
|
+
let obj;
|
|
105
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
106
|
+
const msg = obj && obj.message;
|
|
107
|
+
const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
|
|
108
|
+
: (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
|
|
109
|
+
: null;
|
|
110
|
+
if (!model) continue;
|
|
111
|
+
anyModelSeen = model;
|
|
112
|
+
const isSide = obj.isSidechain === true || obj.is_sidechain === true;
|
|
113
|
+
const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
|
|
114
|
+
if (isSide && ptid) {
|
|
115
|
+
subagentModels[ptid] = model;
|
|
116
|
+
} else if (!isSide) {
|
|
117
|
+
rootModel = model;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!rootModel) rootModel = anyModelSeen;
|
|
121
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return null;
|
|
122
|
+
return { rootModel, subagentModels };
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
|
|
129
|
+
* `<projects>/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl` with a
|
|
130
|
+
* sidecar `.meta.json` carrying `{agentType, description}`. The hook
|
|
131
|
+
* payload's `agent_id` matches the file's <agentId>, so the reducer can
|
|
132
|
+
* attribute via the existing `subagentModels` map (it keys by parentToolUseId
|
|
133
|
+
* but the reducer looks up `${sessionId}::${key}` and the subagent node id
|
|
134
|
+
* is built from `agent_id` — identical lookup either way).
|
|
135
|
+
*
|
|
136
|
+
* Returns { [agentId]: model } scanning every agent-*.jsonl file in dir. */
|
|
137
|
+
async function readSubagentModelsFromDir(transcriptPath) {
|
|
138
|
+
// Subagent dir sits next to the main jsonl: <dir>/<sessionId>/subagents/
|
|
139
|
+
// Derive from transcript_path by stripping the .jsonl suffix.
|
|
140
|
+
if (!transcriptPath || typeof transcriptPath !== "string") return null;
|
|
141
|
+
const sessionDir = transcriptPath.replace(/\.jsonl$/i, "");
|
|
142
|
+
const subDir = join(sessionDir, "subagents");
|
|
143
|
+
let entries;
|
|
144
|
+
try { entries = await readdir(subDir); } catch { return null; }
|
|
145
|
+
const models = {};
|
|
146
|
+
for (const f of entries) {
|
|
147
|
+
if (!/^agent-([0-9a-f]+)\.jsonl$/i.test(f)) continue;
|
|
148
|
+
const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
|
|
149
|
+
const full = join(subDir, f);
|
|
150
|
+
try {
|
|
151
|
+
const s = await stat(full);
|
|
152
|
+
if (s.size === 0) continue;
|
|
153
|
+
const fh = await open(full, "r");
|
|
154
|
+
let text;
|
|
155
|
+
try {
|
|
156
|
+
const buf = Buffer.alloc(s.size);
|
|
157
|
+
await fh.read(buf, 0, s.size, 0);
|
|
158
|
+
text = buf.toString("utf8");
|
|
159
|
+
} finally {
|
|
160
|
+
await fh.close();
|
|
161
|
+
}
|
|
162
|
+
// Last-seen claude-* model wins — subagents may switch model mid-turn
|
|
163
|
+
// (Sonnet → Haiku for tool-call fallback etc.).
|
|
164
|
+
let last = null;
|
|
165
|
+
for (const line of text.split("\n")) {
|
|
166
|
+
if (!line) continue;
|
|
167
|
+
let obj;
|
|
168
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
169
|
+
const msg = obj && obj.message;
|
|
170
|
+
const m = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
|
|
171
|
+
: (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
|
|
172
|
+
: null;
|
|
173
|
+
if (m) last = m;
|
|
174
|
+
}
|
|
175
|
+
if (last) models[agentId] = last;
|
|
176
|
+
} catch { /* skip unreadable file */ }
|
|
177
|
+
}
|
|
178
|
+
return Object.keys(models).length ? models : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function maybeResolveModel(payload) {
|
|
182
|
+
if (!payload || typeof payload !== "object") return;
|
|
183
|
+
const sid = payload.session_id;
|
|
184
|
+
const tp = payload.transcript_path;
|
|
185
|
+
if (!sid || !tp) return;
|
|
186
|
+
// Re-read on every event for this session — the cache was preventing us
|
|
187
|
+
// from picking up subagent models that arrive after the root is known.
|
|
188
|
+
// Throttle so we don't thrash the filesystem.
|
|
189
|
+
if (pendingTranscriptReads.has(sid)) return;
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const last = modelLastReadAt.get(sid) ?? 0;
|
|
192
|
+
if (now - last < MODEL_READ_THROTTLE_MS) return;
|
|
193
|
+
modelLastReadAt.set(sid, now);
|
|
194
|
+
pendingTranscriptReads.add(sid);
|
|
195
|
+
Promise.all([readModelFromTranscript(tp), readSubagentModelsFromDir(tp)])
|
|
196
|
+
.then(([result, dirSubs]) => {
|
|
197
|
+
const rootModel = result?.rootModel ?? null;
|
|
198
|
+
// Merge legacy (inline isSidechain) + new (subagents/ dir) maps. Dir
|
|
199
|
+
// wins on conflict since current CC only writes to the dir.
|
|
200
|
+
const subagentModels = { ...(result?.subagentModels ?? {}), ...(dirSubs ?? {}) };
|
|
201
|
+
if (!rootModel && Object.keys(subagentModels).length === 0) return;
|
|
202
|
+
const prev = modelBySession.get(sid);
|
|
203
|
+
const subsSig = JSON.stringify(subagentModels);
|
|
204
|
+
if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
|
|
205
|
+
modelBySession.set(sid, { rootModel, subsSig });
|
|
206
|
+
pushEvent({
|
|
207
|
+
hook_event_name: "ModelObserved",
|
|
208
|
+
session_id: sid,
|
|
209
|
+
model: rootModel,
|
|
210
|
+
subagentModels,
|
|
211
|
+
}, "internal");
|
|
212
|
+
})
|
|
213
|
+
.catch(() => {})
|
|
214
|
+
.finally(() => pendingTranscriptReads.delete(sid));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Usage enrichment ────────────────────────────────────────────────────
|
|
218
|
+
// Same story as the model: token counts (input/output/cache) are missing
|
|
219
|
+
// from every CC hook payload but present on every assistant message in
|
|
220
|
+
// the transcript JSONL as a `"usage":{…}` block. We sum them across the
|
|
221
|
+
// whole transcript and ship a synthetic UsageObserved event so the
|
|
222
|
+
// session's root agent gets accurate cumulative usage (and therefore the
|
|
223
|
+
// cost columns actually have something to multiply by).
|
|
224
|
+
const lastUsageReadAt = new Map(); // sid -> ms timestamp
|
|
225
|
+
const pendingUsageReads = new Set(); // sid currently being read
|
|
226
|
+
const USAGE_READ_THROTTLE_MS = 2500;
|
|
227
|
+
|
|
228
|
+
async function readUsageFromTranscript(path) {
|
|
229
|
+
try {
|
|
230
|
+
const s = await stat(path);
|
|
231
|
+
if (s.size === 0) return null;
|
|
232
|
+
// Transcripts can grow large (thinking blocks, tool inputs) — read the
|
|
233
|
+
// whole file. Each entry has its own usage object and we sum every
|
|
234
|
+
// occurrence, so missing earlier bytes would undercount. Files are
|
|
235
|
+
// usually < 1MB; tens-of-MB sessions cost a few ms to scan.
|
|
236
|
+
const fh = await open(path, "r");
|
|
237
|
+
let buf;
|
|
238
|
+
try {
|
|
239
|
+
buf = Buffer.alloc(s.size);
|
|
240
|
+
await fh.read(buf, 0, s.size, 0);
|
|
241
|
+
} finally {
|
|
242
|
+
await fh.close();
|
|
243
|
+
}
|
|
244
|
+
const text = buf.toString("utf8");
|
|
245
|
+
const totals = {
|
|
246
|
+
input_tokens: 0, output_tokens: 0,
|
|
247
|
+
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
|
|
248
|
+
};
|
|
249
|
+
// Match each `"usage":{...}` block and sum the four numeric fields.
|
|
250
|
+
// Regex is good enough — these blocks are flat single-level JSON.
|
|
251
|
+
const re = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
252
|
+
const grab = (blob, key) => {
|
|
253
|
+
const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
|
|
254
|
+
return km ? Number(km[1]) : 0;
|
|
255
|
+
};
|
|
256
|
+
for (const m of text.matchAll(re)) {
|
|
257
|
+
const blob = m[1];
|
|
258
|
+
totals.input_tokens += grab(blob, "input_tokens");
|
|
259
|
+
totals.output_tokens += grab(blob, "output_tokens");
|
|
260
|
+
totals.cache_read_input_tokens += grab(blob, "cache_read_input_tokens");
|
|
261
|
+
totals.cache_creation_input_tokens += grab(blob, "cache_creation_input_tokens");
|
|
262
|
+
}
|
|
263
|
+
if (totals.input_tokens === 0 && totals.output_tokens === 0
|
|
264
|
+
&& totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
|
|
265
|
+
return totals;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function maybeResolveUsage(payload) {
|
|
272
|
+
if (!payload || typeof payload !== "object") return;
|
|
273
|
+
const sid = payload.session_id;
|
|
274
|
+
const tp = payload.transcript_path;
|
|
275
|
+
if (!sid || !tp) return;
|
|
276
|
+
if (pendingUsageReads.has(sid)) return;
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
const last = lastUsageReadAt.get(sid) ?? 0;
|
|
279
|
+
if (now - last < USAGE_READ_THROTTLE_MS) return;
|
|
280
|
+
lastUsageReadAt.set(sid, now);
|
|
281
|
+
pendingUsageReads.add(sid);
|
|
282
|
+
readUsageFromTranscript(tp)
|
|
283
|
+
.then(usage => {
|
|
284
|
+
if (!usage) return;
|
|
285
|
+
pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage }, "internal");
|
|
286
|
+
})
|
|
287
|
+
.catch(() => {})
|
|
288
|
+
.finally(() => pendingUsageReads.delete(sid));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Context enrichment ──────────────────────────────────────────────────
|
|
292
|
+
// Approximation of `/context` since CC doesn't expose its breakdown via
|
|
293
|
+
// hooks. We scan the transcript JSONL for message counts (user / assistant
|
|
294
|
+
// / tool_use / tool_result / system-reminders) and walk up from cwd for
|
|
295
|
+
// any CLAUDE.md files in scope. Token totals come from UsageObserved; this
|
|
296
|
+
// scan is purely structural ("what does the context contain").
|
|
297
|
+
const lastContextReadAt = new Map();
|
|
298
|
+
const pendingContextReads = new Set();
|
|
299
|
+
const CONTEXT_READ_THROTTLE_MS = 4000;
|
|
300
|
+
|
|
301
|
+
async function readContextFromTranscript(path) {
|
|
302
|
+
try {
|
|
303
|
+
const s = await stat(path);
|
|
304
|
+
if (s.size === 0) return null;
|
|
305
|
+
const fh = await open(path, "r");
|
|
306
|
+
let buf;
|
|
307
|
+
try { buf = Buffer.alloc(s.size); await fh.read(buf, 0, s.size, 0); }
|
|
308
|
+
finally { await fh.close(); }
|
|
309
|
+
const fullText = buf.toString("utf8");
|
|
310
|
+
// CC `/clear` writes a user message `<command-name>/clear</command-name>`
|
|
311
|
+
// into the same transcript file and resets its in-memory context window
|
|
312
|
+
// to ~0, but the JSONL keeps growing — every usage block before the
|
|
313
|
+
// clear marker is stale (pre-reset) and reading the LAST one made the
|
|
314
|
+
// donut report ~100% even though CC's actual context was empty. Same
|
|
315
|
+
// applies to `/compact`: it writes a summary and starts a fresh context.
|
|
316
|
+
// Slice the transcript to the segment AFTER the most recent reset so
|
|
317
|
+
// counts/usage reflect what CC is actually carrying forward.
|
|
318
|
+
const resetRe = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
|
|
319
|
+
let lastResetIdx = -1;
|
|
320
|
+
for (const m of fullText.matchAll(resetRe)) {
|
|
321
|
+
lastResetIdx = (m.index ?? -1) + m[0].length;
|
|
322
|
+
}
|
|
323
|
+
const text = lastResetIdx >= 0 ? fullText.slice(lastResetIdx) : fullText;
|
|
324
|
+
const breakdown = {
|
|
325
|
+
msgsUser: 0,
|
|
326
|
+
msgsAssistant: 0,
|
|
327
|
+
toolUses: 0,
|
|
328
|
+
toolResults: 0,
|
|
329
|
+
systemReminders: 0,
|
|
330
|
+
currentContextTokens: 0,
|
|
331
|
+
};
|
|
332
|
+
breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
|
|
333
|
+
breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
|
|
334
|
+
breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
|
|
335
|
+
breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
|
|
336
|
+
breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
|
|
337
|
+
// Current context size = input + cache_read + cache_create on the LAST
|
|
338
|
+
// usage block in the post-reset slice. If the user just ran /clear and
|
|
339
|
+
// hasn't sent a new prompt yet, this stays 0 (no usage blocks yet) —
|
|
340
|
+
// matches what CC's own `/context` would report.
|
|
341
|
+
const re = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
342
|
+
const grab = (blob, key) => {
|
|
343
|
+
const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
|
|
344
|
+
return km ? Number(km[1]) : 0;
|
|
345
|
+
};
|
|
346
|
+
let lastBlob = null;
|
|
347
|
+
for (const m of text.matchAll(re)) lastBlob = m[1];
|
|
348
|
+
if (lastBlob) {
|
|
349
|
+
breakdown.currentContextTokens =
|
|
350
|
+
grab(lastBlob, "input_tokens") +
|
|
351
|
+
grab(lastBlob, "cache_read_input_tokens") +
|
|
352
|
+
grab(lastBlob, "cache_creation_input_tokens");
|
|
353
|
+
}
|
|
354
|
+
return breakdown;
|
|
355
|
+
} catch { return null; }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Encode an absolute path the way CC stores it under
|
|
359
|
+
* ~/.claude/projects/<slug>/. Drive letters, colons, and path separators
|
|
360
|
+
* are flattened to "-" so the slug survives as a single directory name. */
|
|
361
|
+
function ccProjectSlug(cwd) {
|
|
362
|
+
if (!cwd) return "";
|
|
363
|
+
// Replace path separators and the Windows drive colon. Match CC's own
|
|
364
|
+
// encoding: every \\ / : → - (no collapsing of adjacent dashes).
|
|
365
|
+
return resolve(cwd).replace(/[\\/:]/g, "-");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function scanClaudeMdFiles(cwd) {
|
|
369
|
+
if (!cwd || typeof cwd !== "string") return [];
|
|
370
|
+
const found = [];
|
|
371
|
+
const seen = new Set();
|
|
372
|
+
const home = homedir();
|
|
373
|
+
const push = async (p) => {
|
|
374
|
+
if (seen.has(p)) return;
|
|
375
|
+
seen.add(p);
|
|
376
|
+
try {
|
|
377
|
+
const s = await stat(p);
|
|
378
|
+
if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
|
|
379
|
+
} catch {}
|
|
380
|
+
};
|
|
381
|
+
// Walk up from cwd to filesystem root. At each dir, check for the
|
|
382
|
+
// canonical CC memory filenames plus CLAUDE.local.md (user-private).
|
|
383
|
+
let dir = resolve(cwd);
|
|
384
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
385
|
+
for (const rel of [
|
|
386
|
+
"CLAUDE.md",
|
|
387
|
+
"CLAUDE.local.md",
|
|
388
|
+
join(".claude", "CLAUDE.md"),
|
|
389
|
+
join(".claude", "CLAUDE.local.md"),
|
|
390
|
+
]) {
|
|
391
|
+
await push(join(dir, rel));
|
|
392
|
+
}
|
|
393
|
+
const parent = pdirname(dir);
|
|
394
|
+
if (parent === dir) break;
|
|
395
|
+
dir = parent;
|
|
396
|
+
}
|
|
397
|
+
// User-global memory.
|
|
398
|
+
await push(join(home, ".claude", "CLAUDE.md"));
|
|
399
|
+
await push(join(home, ".claude", "CLAUDE.local.md"));
|
|
400
|
+
// Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
|
|
401
|
+
// (plus MEMORY.md index). CC injects these into context for sessions
|
|
402
|
+
// whose cwd matches the slug.
|
|
403
|
+
const slug = ccProjectSlug(cwd);
|
|
404
|
+
if (slug) {
|
|
405
|
+
const memDir = join(home, ".claude", "projects", slug, "memory");
|
|
406
|
+
try {
|
|
407
|
+
const entries = await readdir(memDir);
|
|
408
|
+
for (const f of entries) {
|
|
409
|
+
if (f.toLowerCase().endsWith(".md")) await push(join(memDir, f));
|
|
410
|
+
}
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
return found;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function maybeResolveContext(payload) {
|
|
417
|
+
if (!payload || typeof payload !== "object") return;
|
|
418
|
+
const sid = payload.session_id;
|
|
419
|
+
const tp = payload.transcript_path;
|
|
420
|
+
const cwd = payload.cwd;
|
|
421
|
+
if (!sid || !tp) return;
|
|
422
|
+
if (pendingContextReads.has(sid)) return;
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
const last = lastContextReadAt.get(sid) ?? 0;
|
|
425
|
+
if (now - last < CONTEXT_READ_THROTTLE_MS) return;
|
|
426
|
+
lastContextReadAt.set(sid, now);
|
|
427
|
+
pendingContextReads.add(sid);
|
|
428
|
+
Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
|
|
429
|
+
.then(([breakdown, claudeMdFiles]) => {
|
|
430
|
+
if (!breakdown && (!claudeMdFiles || claudeMdFiles.length === 0)) return;
|
|
431
|
+
pushEvent({
|
|
432
|
+
hook_event_name: "ContextObserved",
|
|
433
|
+
session_id: sid,
|
|
434
|
+
context: {
|
|
435
|
+
...(breakdown ?? {}),
|
|
436
|
+
claudeMdFiles: claudeMdFiles ?? [],
|
|
437
|
+
},
|
|
438
|
+
}, "internal");
|
|
439
|
+
})
|
|
440
|
+
.catch(() => {})
|
|
441
|
+
.finally(() => pendingContextReads.delete(sid));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Codex transcript enrichment ──────────────────────────────────────────
|
|
445
|
+
// Codex CLI hook payloads carry `session_id` but no transcript path. Sessions
|
|
446
|
+
// are persisted to ~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl with one
|
|
447
|
+
// JSON object per line: {type, payload}. Token usage shows up in
|
|
448
|
+
// {type:"event_msg", payload:{type:"token_count",
|
|
449
|
+
// info:{total_token_usage:{input_tokens, cached_input_tokens,
|
|
450
|
+
// output_tokens, reasoning_output_tokens,
|
|
451
|
+
// total_tokens}}}}
|
|
452
|
+
// We resolve the rollout path lazily (cache sid→path), then read the tail
|
|
453
|
+
// for usage + model. CODEX_HOME overrides ~/.codex.
|
|
454
|
+
const CODEX_HOME = process.env.CODEX_HOME
|
|
455
|
+
? resolve(process.env.CODEX_HOME)
|
|
456
|
+
: join(homedir(), ".codex");
|
|
457
|
+
const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
|
|
458
|
+
const codexRolloutPathBySid = new Map();
|
|
459
|
+
const lastCodexUsageReadAt = new Map();
|
|
460
|
+
const pendingCodexUsageReads = new Set();
|
|
461
|
+
const CODEX_READ_THROTTLE_MS = 2500;
|
|
462
|
+
|
|
463
|
+
async function findCodexRolloutPath(sid) {
|
|
464
|
+
const cached = codexRolloutPathBySid.get(sid);
|
|
465
|
+
if (cached) return cached;
|
|
466
|
+
// Walk year → month → day → files. Codex includes the sid in the filename
|
|
467
|
+
// (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
|
|
468
|
+
const tryYears = async () => {
|
|
469
|
+
try { return await readdir(CODEX_SESSIONS_DIR); } catch { return []; }
|
|
470
|
+
};
|
|
471
|
+
const years = (await tryYears()).sort().reverse(); // newest first
|
|
472
|
+
for (const y of years) {
|
|
473
|
+
let months;
|
|
474
|
+
try { months = (await readdir(join(CODEX_SESSIONS_DIR, y))).sort().reverse(); }
|
|
475
|
+
catch { continue; }
|
|
476
|
+
for (const m of months) {
|
|
477
|
+
let days;
|
|
478
|
+
try { days = (await readdir(join(CODEX_SESSIONS_DIR, y, m))).sort().reverse(); }
|
|
479
|
+
catch { continue; }
|
|
480
|
+
for (const d of days) {
|
|
481
|
+
const dayDir = join(CODEX_SESSIONS_DIR, y, m, d);
|
|
482
|
+
let files;
|
|
483
|
+
try { files = await readdir(dayDir); } catch { continue; }
|
|
484
|
+
const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
|
|
485
|
+
if (hit) {
|
|
486
|
+
const full = join(dayDir, hit);
|
|
487
|
+
codexRolloutPathBySid.set(sid, full);
|
|
488
|
+
return full;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Tail-read a Codex rollout JSONL. Returns the last token_count info block
|
|
497
|
+
* plus the most recent observed model + the session's model_context_window
|
|
498
|
+
* (set once from task_started). */
|
|
499
|
+
async function readCodexRollout(path) {
|
|
500
|
+
try {
|
|
501
|
+
const s = await stat(path);
|
|
502
|
+
if (s.size === 0) return null;
|
|
503
|
+
const fh = await open(path, "r");
|
|
504
|
+
let text;
|
|
505
|
+
try {
|
|
506
|
+
const buf = Buffer.alloc(s.size);
|
|
507
|
+
await fh.read(buf, 0, s.size, 0);
|
|
508
|
+
text = buf.toString("utf8");
|
|
509
|
+
} finally {
|
|
510
|
+
await fh.close();
|
|
511
|
+
}
|
|
512
|
+
let lastUsage = null;
|
|
513
|
+
let model = null;
|
|
514
|
+
let contextWindow = null;
|
|
515
|
+
let cwd = null;
|
|
516
|
+
for (const line of text.split("\n")) {
|
|
517
|
+
if (!line) continue;
|
|
518
|
+
let obj;
|
|
519
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
520
|
+
const type = obj && obj.type;
|
|
521
|
+
const pl = obj && obj.payload;
|
|
522
|
+
if (type === "session_meta" && pl) {
|
|
523
|
+
if (typeof pl.cwd === "string") cwd = pl.cwd;
|
|
524
|
+
// session_meta sometimes carries the model in newer Codex versions.
|
|
525
|
+
if (typeof pl.model === "string") model = pl.model;
|
|
526
|
+
} else if (type === "event_msg" && pl) {
|
|
527
|
+
if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
|
|
528
|
+
lastUsage = pl.info.total_token_usage;
|
|
529
|
+
} else if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
|
|
530
|
+
contextWindow = pl.model_context_window;
|
|
531
|
+
}
|
|
532
|
+
} else if (type === "response_item" && pl && typeof pl.model === "string") {
|
|
533
|
+
// Fallback model source — response items carry the model id.
|
|
534
|
+
model = pl.model;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (!lastUsage && !model && !contextWindow) return null;
|
|
538
|
+
return { usage: lastUsage, model, contextWindow, cwd };
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function maybeResolveCodex(payload) {
|
|
545
|
+
if (!payload || typeof payload !== "object") return;
|
|
546
|
+
if (payload.provider !== "codex") return;
|
|
547
|
+
const sid = payload.session_id;
|
|
548
|
+
if (!sid) return;
|
|
549
|
+
if (pendingCodexUsageReads.has(sid)) return;
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
const last = lastCodexUsageReadAt.get(sid) ?? 0;
|
|
552
|
+
if (now - last < CODEX_READ_THROTTLE_MS) return;
|
|
553
|
+
lastCodexUsageReadAt.set(sid, now);
|
|
554
|
+
pendingCodexUsageReads.add(sid);
|
|
555
|
+
(async () => {
|
|
556
|
+
const path = await findCodexRolloutPath(sid);
|
|
557
|
+
if (!path) return;
|
|
558
|
+
const r = await readCodexRollout(path);
|
|
559
|
+
if (!r) return;
|
|
560
|
+
if (r.usage) {
|
|
561
|
+
pushEvent({
|
|
562
|
+
hook_event_name: "UsageObserved",
|
|
563
|
+
session_id: sid,
|
|
564
|
+
usage: r.usage,
|
|
565
|
+
}, "internal");
|
|
566
|
+
}
|
|
567
|
+
if (r.model) {
|
|
568
|
+
pushEvent({
|
|
569
|
+
hook_event_name: "ModelObserved",
|
|
570
|
+
session_id: sid,
|
|
571
|
+
model: r.model,
|
|
572
|
+
}, "internal");
|
|
573
|
+
}
|
|
574
|
+
if (r.contextWindow) {
|
|
575
|
+
// Piggy-back on the model event with the window — reducer reads
|
|
576
|
+
// model_context_window directly off any payload.
|
|
577
|
+
pushEvent({
|
|
578
|
+
hook_event_name: "ModelObserved",
|
|
579
|
+
session_id: sid,
|
|
580
|
+
model: r.model ?? undefined,
|
|
581
|
+
model_context_window: r.contextWindow,
|
|
582
|
+
}, "internal");
|
|
583
|
+
}
|
|
584
|
+
})()
|
|
585
|
+
.catch(() => {})
|
|
586
|
+
.finally(() => pendingCodexUsageReads.delete(sid));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ─── Codex rollout watcher ────────────────────────────────────────────────
|
|
590
|
+
// Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
|
|
591
|
+
// refuses to spawn the hook command (exit 1, child never runs). So instead of
|
|
592
|
+
// relying on hooks, we tail the rollout JSONL files Codex writes to
|
|
593
|
+
// ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl and reconstruct the
|
|
594
|
+
// agent-dag event stream from them. Each rollout line is one append-only JSON
|
|
595
|
+
// object {timestamp, type, payload}; we map the relevant ones to the same
|
|
596
|
+
// synthetic hook payloads the reducer already understands:
|
|
597
|
+
// session_meta → SessionStart
|
|
598
|
+
// event_msg/user_message → UserPromptSubmit
|
|
599
|
+
// response_item/function_call → PreToolUse
|
|
600
|
+
// response_item/function_call_output → PostToolUse
|
|
601
|
+
// event_msg/token_count → UsageObserved
|
|
602
|
+
// event_msg/task_started (+window) → ModelObserved (context window)
|
|
603
|
+
// turn_context / response_item.model → model snapshot (ModelObserved on change)
|
|
604
|
+
// Events are emitted with source "codex" so pushEvent skips the Claude-only
|
|
605
|
+
// transcript enrichment (which needs transcript_path / hook events) but still
|
|
606
|
+
// persists + broadcasts them exactly like a hook event. This path is entirely
|
|
607
|
+
// additive — the Claude hook flow is untouched.
|
|
608
|
+
const codexFileState = new Map(); // path -> { offset, sid, cwd, skip }
|
|
609
|
+
const codexSessionModel = new Map(); // sid -> last model string
|
|
610
|
+
let codexScanRunning = false;
|
|
611
|
+
let codexWatchTimer = null;
|
|
612
|
+
let codexWorkspace = "";
|
|
613
|
+
|
|
614
|
+
function codexCwdInWorkspace(cwd) {
|
|
615
|
+
if (!codexWorkspace) return true;
|
|
616
|
+
if (!cwd || typeof cwd !== "string") return false;
|
|
617
|
+
const a = resolve(cwd).toLowerCase();
|
|
618
|
+
const b = resolve(codexWorkspace).toLowerCase();
|
|
619
|
+
return a === b || a.startsWith(b + sep.toLowerCase());
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// List rollout files from the newest 2 day-directories. New sessions always
|
|
623
|
+
// land in today's dir, so this captures live activity without scanning years
|
|
624
|
+
// of history every tick.
|
|
625
|
+
async function listRecentCodexRollouts() {
|
|
626
|
+
const out = [];
|
|
627
|
+
let years;
|
|
628
|
+
try { years = (await readdir(CODEX_SESSIONS_DIR)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
|
|
629
|
+
catch { return out; }
|
|
630
|
+
let dayDirs = 0;
|
|
631
|
+
for (const y of years) {
|
|
632
|
+
let months;
|
|
633
|
+
try { months = (await readdir(join(CODEX_SESSIONS_DIR, y))).sort().reverse(); } catch { continue; }
|
|
634
|
+
for (const m of months) {
|
|
635
|
+
let days;
|
|
636
|
+
try { days = (await readdir(join(CODEX_SESSIONS_DIR, y, m))).sort().reverse(); } catch { continue; }
|
|
637
|
+
for (const d of days) {
|
|
638
|
+
const dir = join(CODEX_SESSIONS_DIR, y, m, d);
|
|
639
|
+
let files;
|
|
640
|
+
try { files = await readdir(dir); } catch { continue; }
|
|
641
|
+
for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
|
|
642
|
+
if (++dayDirs >= 2) return out;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function readByteRange(path, from, to) {
|
|
650
|
+
const fh = await open(path, "r");
|
|
651
|
+
try {
|
|
652
|
+
const len = to - from;
|
|
653
|
+
if (len <= 0) return "";
|
|
654
|
+
const buf = Buffer.alloc(len);
|
|
655
|
+
await fh.read(buf, 0, len, from);
|
|
656
|
+
return buf.toString("utf8");
|
|
657
|
+
} finally {
|
|
658
|
+
await fh.close();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Read the first complete JSON line of a rollout (the session_meta header)
|
|
663
|
+
// to learn sid + cwd before we start streaming. The header line can be large
|
|
664
|
+
// (base_instructions text runs tens of KB), so we read in growing chunks until
|
|
665
|
+
// we hit the first newline rather than guessing a fixed window.
|
|
666
|
+
async function readCodexHeader(path) {
|
|
667
|
+
try {
|
|
668
|
+
const size = (await stat(path)).size;
|
|
669
|
+
if (size === 0) return null;
|
|
670
|
+
const CHUNK = 65536;
|
|
671
|
+
let upto = Math.min(CHUNK, size);
|
|
672
|
+
let text = "";
|
|
673
|
+
for (;;) {
|
|
674
|
+
text = await readByteRange(path, 0, upto);
|
|
675
|
+
const nl = text.indexOf("\n");
|
|
676
|
+
if (nl >= 0) {
|
|
677
|
+
const obj = JSON.parse(text.slice(0, nl));
|
|
678
|
+
if (obj && obj.type === "session_meta" && obj.payload) {
|
|
679
|
+
return { sid: obj.payload.id, cwd: typeof obj.payload.cwd === "string" ? obj.payload.cwd : null };
|
|
680
|
+
}
|
|
681
|
+
return null;
|
|
682
|
+
}
|
|
683
|
+
if (upto >= size) return null; // no newline in the whole file yet
|
|
684
|
+
upto = Math.min(upto + CHUNK, size); // grow and retry
|
|
685
|
+
if (upto > 4 * 1024 * 1024) return null; // 4MB sanity cap on a single line
|
|
686
|
+
}
|
|
687
|
+
} catch {}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Map one parsed rollout object to a synthetic hook payload (or null to skip).
|
|
692
|
+
// Mutates codexSessionModel and returns { payload, modelEvent } where
|
|
693
|
+
// modelEvent is an optional ModelObserved to emit first when the model changed.
|
|
694
|
+
function codexObjToPayload(obj, sid, cwd) {
|
|
695
|
+
const type = obj && obj.type;
|
|
696
|
+
const pl = (obj && obj.payload) || {};
|
|
697
|
+
const base = { session_id: sid, cwd, provider: "codex" };
|
|
698
|
+
const model = codexSessionModel.get(sid);
|
|
699
|
+
|
|
700
|
+
// Track model from turn_context / response_item before mapping events.
|
|
701
|
+
if (type === "turn_context" && typeof pl.model === "string") {
|
|
702
|
+
codexSessionModel.set(sid, pl.model);
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
if (type === "response_item" && typeof pl.model === "string") {
|
|
706
|
+
codexSessionModel.set(sid, pl.model);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (type === "event_msg") {
|
|
710
|
+
if (pl.type === "user_message") {
|
|
711
|
+
const prompt = typeof pl.message === "string" ? pl.message : "";
|
|
712
|
+
return { ...base, hook_event_name: "UserPromptSubmit", prompt, model };
|
|
713
|
+
}
|
|
714
|
+
if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
|
|
715
|
+
return { ...base, hook_event_name: "UsageObserved", usage: pl.info.total_token_usage, model };
|
|
716
|
+
}
|
|
717
|
+
if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
|
|
718
|
+
return { ...base, hook_event_name: "ModelObserved", model, model_context_window: pl.model_context_window };
|
|
719
|
+
}
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
if (type === "response_item") {
|
|
723
|
+
if (pl.type === "function_call") {
|
|
724
|
+
let input = pl.arguments;
|
|
725
|
+
try { input = JSON.parse(pl.arguments); } catch {}
|
|
726
|
+
return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: input, tool_use_id: pl.call_id, model };
|
|
727
|
+
}
|
|
728
|
+
if (pl.type === "custom_tool_call") {
|
|
729
|
+
return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: { patch: pl.input }, tool_use_id: pl.call_id, model };
|
|
730
|
+
}
|
|
731
|
+
if (pl.type === "function_call_output" || pl.type === "custom_tool_call_output") {
|
|
732
|
+
const tool_response = pl.output != null ? parseCodexOutput(pl.output) : undefined;
|
|
733
|
+
return { ...base, hook_event_name: "PostToolUse", tool_use_id: pl.call_id, tool_response, model };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function parseCodexOutput(raw) {
|
|
740
|
+
if (typeof raw !== "string") return raw;
|
|
741
|
+
try {
|
|
742
|
+
const o = JSON.parse(raw);
|
|
743
|
+
return (o && typeof o.output === "string") ? o.output : raw;
|
|
744
|
+
} catch {
|
|
745
|
+
return raw;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function emitCodexEvent(payload) {
|
|
750
|
+
pushEvent(payload, "codex");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Emit the SessionStart root exactly once per file, lazily — only when the
|
|
754
|
+
// session actually produces an event. This keeps long-dead sessions that were
|
|
755
|
+
// merely on disk at startup from cluttering the canvas with empty roots.
|
|
756
|
+
function ensureCodexRoot(state) {
|
|
757
|
+
if (state.rootEmitted) return;
|
|
758
|
+
state.rootEmitted = true;
|
|
759
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" });
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function codexScanOnce(firstRun) {
|
|
763
|
+
if (codexScanRunning) return;
|
|
764
|
+
codexScanRunning = true;
|
|
765
|
+
try {
|
|
766
|
+
const files = await listRecentCodexRollouts();
|
|
767
|
+
for (const path of files) {
|
|
768
|
+
let st;
|
|
769
|
+
try { st = await stat(path); } catch { continue; }
|
|
770
|
+
let state = codexFileState.get(path);
|
|
771
|
+
|
|
772
|
+
if (!state) {
|
|
773
|
+
// New file — read the header for sid + cwd, then decide whether to
|
|
774
|
+
// capture it. Skip files outside our workspace.
|
|
775
|
+
const header = await readCodexHeader(path);
|
|
776
|
+
if (!header || !header.sid) continue; // not ready yet — retry next tick
|
|
777
|
+
if (!codexCwdInWorkspace(header.cwd)) {
|
|
778
|
+
codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false });
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false };
|
|
782
|
+
codexFileState.set(path, state);
|
|
783
|
+
if (firstRun) {
|
|
784
|
+
// On startup, skip a pre-existing session's history entirely — no
|
|
785
|
+
// root, no replay. Only future appends (a live session that keeps
|
|
786
|
+
// going) will lazily create the root via ensureCodexRoot.
|
|
787
|
+
state.offset = st.size;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (state.skip) { state.offset = st.size; continue; }
|
|
793
|
+
if (st.size <= state.offset) continue;
|
|
794
|
+
|
|
795
|
+
const text = await readByteRange(path, state.offset, st.size);
|
|
796
|
+
const lastNl = text.lastIndexOf("\n");
|
|
797
|
+
if (lastNl < 0) continue; // no complete line yet
|
|
798
|
+
const consume = text.slice(0, lastNl);
|
|
799
|
+
state.offset += Buffer.byteLength(consume, "utf8") + 1; // +1 for the \n
|
|
800
|
+
|
|
801
|
+
for (const line of consume.split("\n")) {
|
|
802
|
+
if (!line) continue;
|
|
803
|
+
let obj;
|
|
804
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
805
|
+
const prevModel = codexSessionModel.get(state.sid);
|
|
806
|
+
const payload = codexObjToPayload(obj, state.sid, state.cwd);
|
|
807
|
+
// If the model changed (turn_context/response_item), surface it.
|
|
808
|
+
const nowModel = codexSessionModel.get(state.sid);
|
|
809
|
+
if (nowModel && nowModel !== prevModel) {
|
|
810
|
+
ensureCodexRoot(state);
|
|
811
|
+
emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel });
|
|
812
|
+
}
|
|
813
|
+
if (payload) {
|
|
814
|
+
ensureCodexRoot(state);
|
|
815
|
+
emitCodexEvent(payload);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
} catch {
|
|
820
|
+
/* swallow — watcher must never crash the server */
|
|
821
|
+
} finally {
|
|
822
|
+
codexScanRunning = false;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function startCodexWatcher(workspace) {
|
|
827
|
+
codexWorkspace = workspace ?? "";
|
|
828
|
+
if (!existsSync(CODEX_SESSIONS_DIR)) return null;
|
|
829
|
+
// Initial catalog: create roots for in-progress sessions, skip their
|
|
830
|
+
// history, then poll for new lines.
|
|
831
|
+
codexScanOnce(true).catch(() => {});
|
|
832
|
+
codexWatchTimer = setInterval(() => { codexScanOnce(false).catch(() => {}); }, 1500);
|
|
833
|
+
if (codexWatchTimer.unref) codexWatchTimer.unref();
|
|
834
|
+
return codexWatchTimer;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function pushEvent(raw, source, opts = {}) {
|
|
838
|
+
// Synchronous enrichment: if we already know this session's model, stamp
|
|
839
|
+
// it on the payload so the client's recursive scanner picks it up.
|
|
840
|
+
if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
|
|
841
|
+
const cached = modelBySession.get(raw.session_id);
|
|
842
|
+
if (cached) raw.model = cached;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const seq = nextSeq++;
|
|
846
|
+
const evt = {
|
|
847
|
+
seq,
|
|
848
|
+
receivedAt: opts.receivedAt ?? Date.now(),
|
|
849
|
+
source,
|
|
850
|
+
payload: raw,
|
|
851
|
+
};
|
|
852
|
+
events.push(evt);
|
|
853
|
+
if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
|
|
854
|
+
|
|
855
|
+
const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
|
|
856
|
+
for (const res of sseClients) {
|
|
857
|
+
try { res.write(line); } catch {}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (persistPath && !opts.replay) {
|
|
861
|
+
// Fire-and-forget append. JSONL = newline-delimited JSON.
|
|
862
|
+
appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
|
|
863
|
+
// Cheap throttled check (every 30s) — only rotates if file > 50MB.
|
|
864
|
+
maybeRotatePersistFile();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Kick off async transcript scans. Model arrives as a one-shot
|
|
868
|
+
// ModelObserved; usage is re-read periodically (throttled to 2.5s per
|
|
869
|
+
// session) so the cost columns track running totals as the session
|
|
870
|
+
// progresses. Both result in synthetic events.
|
|
871
|
+
// Provider gates the path: Claude reads transcript_path; Codex reads its
|
|
872
|
+
// rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
|
|
873
|
+
// when transcript_path is absent (always the case for Codex hooks).
|
|
874
|
+
if (source === "hook" && !opts.replay) {
|
|
875
|
+
if (raw && raw.provider === "codex") {
|
|
876
|
+
maybeResolveCodex(raw);
|
|
877
|
+
} else {
|
|
878
|
+
maybeResolveModel(raw);
|
|
879
|
+
maybeResolveUsage(raw);
|
|
880
|
+
maybeResolveContext(raw);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return evt;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function replayLog(filePath) {
|
|
888
|
+
if (!existsSync(filePath)) return 0;
|
|
889
|
+
let count = 0;
|
|
890
|
+
const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
|
|
891
|
+
for await (const line of rl) {
|
|
892
|
+
if (!line) continue;
|
|
893
|
+
try {
|
|
894
|
+
const evt = JSON.parse(line);
|
|
895
|
+
if (evt && typeof evt === "object" && evt.payload) {
|
|
896
|
+
pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
|
|
897
|
+
count++;
|
|
898
|
+
}
|
|
899
|
+
} catch { /* skip corrupt line */ }
|
|
900
|
+
}
|
|
901
|
+
return count;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function send(res, status, body, headers = {}) {
|
|
905
|
+
res.writeHead(status, {
|
|
906
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
907
|
+
"Cache-Control": "no-store",
|
|
908
|
+
...headers,
|
|
909
|
+
});
|
|
910
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function serveStatic(req, res, url) {
|
|
914
|
+
// Strip leading slash, default to index.html
|
|
915
|
+
let rel = url.pathname.replace(/^\/+/, "");
|
|
916
|
+
if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
|
|
917
|
+
const filePath = join(WEB_DIST, rel);
|
|
918
|
+
if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
const s = await stat(filePath);
|
|
922
|
+
if (s.isDirectory()) return send(res, 404, { error: "not found" });
|
|
923
|
+
const buf = await readFile(filePath);
|
|
924
|
+
res.writeHead(200, {
|
|
925
|
+
"Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
|
|
926
|
+
"Cache-Control": "no-cache",
|
|
927
|
+
});
|
|
928
|
+
res.end(buf);
|
|
929
|
+
} catch {
|
|
930
|
+
// SPA fallback to index.html for client-side routes
|
|
931
|
+
try {
|
|
932
|
+
const idx = await readFile(join(WEB_DIST, "index.html"));
|
|
933
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
934
|
+
res.end(idx);
|
|
935
|
+
} catch {
|
|
936
|
+
send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function handleEventIngest(req, res) {
|
|
942
|
+
let body = "";
|
|
943
|
+
req.setEncoding("utf8");
|
|
944
|
+
req.on("data", c => {
|
|
945
|
+
body += c;
|
|
946
|
+
if (body.length > 5_000_000) {
|
|
947
|
+
req.destroy();
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
req.on("end", () => {
|
|
951
|
+
let parsed;
|
|
952
|
+
try { parsed = JSON.parse(body); }
|
|
953
|
+
catch { return send(res, 400, { error: "invalid json" }); }
|
|
954
|
+
const evt = pushEvent(parsed, "hook");
|
|
955
|
+
send(res, 200, { ok: true, seq: evt.seq });
|
|
956
|
+
});
|
|
957
|
+
req.on("error", () => send(res, 400, { error: "bad request" }));
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function handleSse(req, res) {
|
|
961
|
+
res.writeHead(200, {
|
|
962
|
+
"Content-Type": "text/event-stream",
|
|
963
|
+
"Cache-Control": "no-cache, no-transform",
|
|
964
|
+
"Connection": "keep-alive",
|
|
965
|
+
"X-Accel-Buffering": "no",
|
|
966
|
+
});
|
|
967
|
+
res.write(`retry: 1500\n\n`);
|
|
968
|
+
|
|
969
|
+
// Resume: replay events after Last-Event-ID. Marked with `replay:true`
|
|
970
|
+
// on the envelope so the client can suppress turn-cleanup side effects
|
|
971
|
+
// (exitAt stamping, autofit churn) until the live stream takes over.
|
|
972
|
+
// Without this the reducer's UserPromptSubmit handler treats replayed
|
|
973
|
+
// events as a real new turn — hiding prior-turn subagents using the
|
|
974
|
+
// event's stale receivedAt, which collides with wall-clock visibility
|
|
975
|
+
// gates and yields the "nodes appear then vanish" symptom on refresh.
|
|
976
|
+
const lastId = Number(req.headers["last-event-id"] ?? 0);
|
|
977
|
+
for (const e of events) {
|
|
978
|
+
if (e.seq <= lastId) continue;
|
|
979
|
+
const tagged = { ...e, replay: true };
|
|
980
|
+
res.write(`id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`);
|
|
981
|
+
}
|
|
982
|
+
// Sentinel: tells client "ring buffer drained, live stream starts now".
|
|
983
|
+
res.write(`event: replay-end\ndata: {}\n\n`);
|
|
984
|
+
|
|
985
|
+
sseClients.add(res);
|
|
986
|
+
const ping = setInterval(() => {
|
|
987
|
+
try { res.write(`: ping\n\n`); } catch {}
|
|
988
|
+
}, 15000);
|
|
989
|
+
|
|
990
|
+
req.on("close", () => {
|
|
991
|
+
clearInterval(ping);
|
|
992
|
+
sseClients.delete(res);
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function handleHealth(_req, res) {
|
|
997
|
+
send(res, 200, {
|
|
998
|
+
ok: true,
|
|
999
|
+
name: "agent-dag",
|
|
1000
|
+
seq: nextSeq - 1,
|
|
1001
|
+
clients: sseClients.size,
|
|
1002
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function isProcessAlive(pid) {
|
|
1007
|
+
try { process.kill(pid, 0); return true; }
|
|
1008
|
+
catch (e) { return e && e.code === "EPERM"; }
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function sweepStaleDiscovery() {
|
|
1012
|
+
const dir = join(homedir(), ".claude", "agent-dag");
|
|
1013
|
+
let files;
|
|
1014
|
+
try { files = await readdir(dir); } catch { return 0; }
|
|
1015
|
+
let removed = 0;
|
|
1016
|
+
for (const f of files) {
|
|
1017
|
+
if (!f.endsWith(".json")) continue;
|
|
1018
|
+
const p = join(dir, f);
|
|
1019
|
+
try {
|
|
1020
|
+
const d = JSON.parse(await readFile(p, "utf8"));
|
|
1021
|
+
if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
|
|
1022
|
+
await unlink(p).catch(() => {});
|
|
1023
|
+
removed++;
|
|
1024
|
+
}
|
|
1025
|
+
} catch { /* corrupt — leave it */ }
|
|
1026
|
+
}
|
|
1027
|
+
return removed;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function randomPort(min, max) {
|
|
1031
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function tryListen(server, port, host) {
|
|
1035
|
+
return new Promise((res, rej) => {
|
|
1036
|
+
server.once("error", rej);
|
|
1037
|
+
server.listen(port, host, () => {
|
|
1038
|
+
server.removeListener("error", rej);
|
|
1039
|
+
res();
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true } = {}) {
|
|
1045
|
+
const removed = await sweepStaleDiscovery();
|
|
1046
|
+
if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
|
|
1047
|
+
if (persist) {
|
|
1048
|
+
persistPath = resolve(persist);
|
|
1049
|
+
try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
|
|
1050
|
+
const replayed = await replayLog(persistPath);
|
|
1051
|
+
if (replayed > 0) {
|
|
1052
|
+
// Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
|
|
1053
|
+
// already. Just keep the buffer + seq counter primed.
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
const server = createServer((req, res) => {
|
|
1057
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
|
|
1058
|
+
|
|
1059
|
+
if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
|
|
1060
|
+
if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
|
|
1061
|
+
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
1062
|
+
|
|
1063
|
+
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
1064
|
+
const since = Number(url.searchParams.get("since") ?? 0);
|
|
1065
|
+
return send(res, 200, events.filter(e => e.seq > since));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
|
|
1069
|
+
if (req.method === "POST" && url.pathname === "/api/clear") {
|
|
1070
|
+
events.length = 0;
|
|
1071
|
+
if (persistPath) truncate(persistPath, 0).catch(() => {});
|
|
1072
|
+
pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
|
|
1073
|
+
return send(res, 200, { ok: true });
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (req.method === "GET") return serveStatic(req, res, url);
|
|
1077
|
+
send(res, 405, { error: "method not allowed" });
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
// Try requested port first, then up to 10 random ports from portRange.
|
|
1081
|
+
const candidates = [port];
|
|
1082
|
+
for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
|
|
1083
|
+
|
|
1084
|
+
for (const candidate of candidates) {
|
|
1085
|
+
try {
|
|
1086
|
+
await tryListen(server, candidate, host);
|
|
1087
|
+
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
1088
|
+
if (codex) startCodexWatcher(workspace);
|
|
1089
|
+
return server;
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
if (err && err.code === "EADDRINUSE") continue;
|
|
1092
|
+
throw err;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
throw Object.assign(new Error(`all ports tried — none available`), { code: "EADDRINUSE" });
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Allow running this file directly for dev.
|
|
1099
|
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1100
|
+
const port = Number(process.env.CCGRAPH_PORT ?? 4317);
|
|
1101
|
+
startServer({ port }).then(s => {
|
|
1102
|
+
const addr = s.address();
|
|
1103
|
+
const p = typeof addr === "object" && addr ? addr.port : port;
|
|
1104
|
+
console.log(`agents-deck server: http://127.0.0.1:${p}`);
|
|
1105
|
+
}).catch(e => {
|
|
1106
|
+
console.error("agents-deck server failed:", e.message);
|
|
1107
|
+
process.exit(1);
|
|
1108
|
+
});
|
|
1109
|
+
}
|