noctrace 1.3.0 → 1.4.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.
@@ -7,7 +7,7 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-BGW0xA7n.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-BBxFz4Ap.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-DlKrxvV-.css">
12
12
  </head>
13
13
  <body>
@@ -736,6 +736,14 @@ export function parseJsonlContent(content) {
736
736
  parentToolUseId: null,
737
737
  });
738
738
  }
739
+ // Chronological sort: tool rows, turn rows, api-errors, and hook rows are pushed
740
+ // in separate passes above. Without this sort, turn rows cluster at the end of
741
+ // the waterfall instead of interleaving with the tool calls they happened between.
742
+ top.sort((a, b) => {
743
+ if (a.startTime !== b.startTime)
744
+ return a.startTime - b.startTime;
745
+ return (a.sequence ?? 0) - (b.sequence ?? 0);
746
+ });
739
747
  return top;
740
748
  }
741
749
  /**
@@ -935,10 +943,15 @@ export function parseSubAgentContent(content) {
935
943
  parentToolUseId: null,
936
944
  });
937
945
  }
938
- // Compute per-row token delta for sub-agent rows
939
- const sorted = [...rows].sort((a, b) => a.startTime - b.startTime);
946
+ // Chronological sort same rule as parseJsonlContent.
947
+ rows.sort((a, b) => {
948
+ if (a.startTime !== b.startTime)
949
+ return a.startTime - b.startTime;
950
+ return (a.sequence ?? 0) - (b.sequence ?? 0);
951
+ });
952
+ // Compute per-row token delta over the now-sorted rows.
940
953
  let prevInput = 0;
941
- for (const row of sorted) {
954
+ for (const row of rows) {
942
955
  row.tokenDelta = row.inputTokens > 0 ? Math.max(0, row.inputTokens - prevInput) : 0;
943
956
  if (row.inputTokens > 0)
944
957
  prevInput = row.inputTokens;
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Codex CLI provider implementation.
3
+ * Parses OpenAI Codex CLI session JSONL rollout files into WaterfallRow[].
4
+ *
5
+ * Session id format (rawSlug): the path from the `sessions/` directory onward.
6
+ * e.g. '2026/04/15/rollout-2026-04-15T09-00-00-abc123.jsonl'
7
+ *
8
+ * Default home: ~/.codex Override: CODEX_HOME env var or codexHome constructor param.
9
+ *
10
+ * Record types handled:
11
+ * SessionMeta, TurnContext, EventMsg (TurnStarted, TurnComplete, TokenCount,
12
+ * ExecCommandEnd), ResponseItem (FunctionCall, FunctionCallOutput, assistant message)
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import chokidar from 'chokidar';
18
+ // ---------------------------------------------------------------------------
19
+ // Capability descriptor
20
+ // ---------------------------------------------------------------------------
21
+ const CODEX_CAPABILITIES = {
22
+ toolCallGranularity: 'full',
23
+ contextTracking: true,
24
+ subAgents: true,
25
+ realtime: true,
26
+ tokenAccounting: 'per-turn',
27
+ };
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+ /** Resolve Codex home directory, respecting CODEX_HOME env var. */
32
+ function resolveCodexHome(override) {
33
+ if (override)
34
+ return override;
35
+ return process.env['CODEX_HOME'] ?? path.join(os.homedir(), '.codex');
36
+ }
37
+ /** Read the mtime of a file; returns null on error. */
38
+ async function safeStatMtime(filePath) {
39
+ try {
40
+ const stat = await fs.stat(filePath);
41
+ return stat.mtime;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** Convert cwd path to a display form, replacing home directory prefix with ~. */
48
+ function toProjectContext(cwd) {
49
+ if (!cwd)
50
+ return 'unknown';
51
+ const home = os.homedir();
52
+ if (cwd.startsWith(home))
53
+ return '~' + cwd.slice(home.length);
54
+ return cwd;
55
+ }
56
+ /** Parse a single line defensively; returns null on malformed input. */
57
+ function parseLine(line) {
58
+ const t = line.trim();
59
+ if (!t)
60
+ return null;
61
+ try {
62
+ const parsed = JSON.parse(t);
63
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
64
+ return null;
65
+ return parsed;
66
+ }
67
+ catch {
68
+ console.warn('[noctrace] codex provider: skipping malformed JSONL line:', t.slice(0, 80));
69
+ return null;
70
+ }
71
+ }
72
+ /** Extract a nested value from an event object by key path. */
73
+ function getEventVariant(event, key) {
74
+ const v = event[key];
75
+ if (typeof v !== 'object' || v === null)
76
+ return null;
77
+ return v;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Core parser
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Parse a Codex rollout JSONL string into WaterfallRow[].
84
+ * Skips malformed lines with console.warn — never throws.
85
+ */
86
+ export function parseCodexContent(content) {
87
+ const lines = content.split('\n');
88
+ const rows = [];
89
+ // State for pairing and timing
90
+ /** Pending FunctionCall rows awaiting their FunctionCallOutput. */
91
+ const callMap = new Map();
92
+ /** All rows by call_id (including completed ones), for retroactive ExecCommandEnd patching. */
93
+ const rowByCallId = new Map();
94
+ const turnTokens = new Map();
95
+ let latestTurnId = null;
96
+ let latestTurnStartMs = 0;
97
+ let latestTokenData = null;
98
+ let sequence = 0;
99
+ for (const line of lines) {
100
+ const rec = parseLine(line);
101
+ if (!rec)
102
+ continue;
103
+ const recType = rec['type'];
104
+ const timestamp = typeof rec['timestamp'] === 'string'
105
+ ? new Date(rec['timestamp']).getTime()
106
+ : Date.now();
107
+ if (recType === 'EventMsg') {
108
+ const event = rec['event'];
109
+ if (typeof event !== 'object' || event === null)
110
+ continue;
111
+ const ev = event;
112
+ const turnStarted = getEventVariant(ev, 'TurnStarted');
113
+ if (turnStarted) {
114
+ latestTurnId = typeof turnStarted['turn_id'] === 'string' ? turnStarted['turn_id'] : null;
115
+ latestTurnStartMs = timestamp;
116
+ continue;
117
+ }
118
+ const turnComplete = getEventVariant(ev, 'TurnComplete');
119
+ if (turnComplete) {
120
+ latestTurnId = typeof turnComplete['turn_id'] === 'string' ? turnComplete['turn_id'] : latestTurnId;
121
+ continue;
122
+ }
123
+ const tokenCount = getEventVariant(ev, 'TokenCount');
124
+ if (tokenCount) {
125
+ const info = tokenCount['info'];
126
+ if (info?.total_token_usage) {
127
+ const td = {
128
+ inputTokens: info.total_token_usage.input_tokens,
129
+ outputTokens: info.total_token_usage.output_tokens,
130
+ cachedInputTokens: info.total_token_usage.cached_input_tokens,
131
+ contextWindow: info.model_context_window,
132
+ };
133
+ latestTokenData = td;
134
+ if (latestTurnId)
135
+ turnTokens.set(latestTurnId, td);
136
+ }
137
+ continue;
138
+ }
139
+ const execEnd = getEventVariant(ev, 'ExecCommandEnd');
140
+ if (execEnd) {
141
+ const execData = execEnd;
142
+ const isFailure = execData.timed_out === true || execData.exit_code !== 0;
143
+ if (isFailure && execData.call_id) {
144
+ // Patch the row retroactively (ExecCommandEnd may arrive after FunctionCallOutput)
145
+ const target = rowByCallId.get(execData.call_id) ?? callMap.get(execData.call_id)?.row;
146
+ if (target) {
147
+ target.isFailure = true;
148
+ target.status = 'error';
149
+ }
150
+ }
151
+ continue;
152
+ }
153
+ continue;
154
+ }
155
+ if (recType === 'ResponseItem') {
156
+ const item = rec['item'];
157
+ if (typeof item !== 'object' || item === null)
158
+ continue;
159
+ const it = item;
160
+ // FunctionCallOutput: pairs with a pending FunctionCall
161
+ if (typeof it['call_id'] === 'string' && typeof it['output'] === 'string' && !it['name'] && !it['role']) {
162
+ const output = it;
163
+ const pending = callMap.get(output.call_id);
164
+ if (pending) {
165
+ const { row } = pending;
166
+ row.endTime = timestamp;
167
+ row.duration = timestamp - row.startTime;
168
+ row.output = output.output;
169
+ row.status = 'success';
170
+ callMap.delete(output.call_id);
171
+ // Keep in rowByCallId so ExecCommandEnd can patch retroactively
172
+ rowByCallId.set(output.call_id, row);
173
+ }
174
+ continue;
175
+ }
176
+ // FunctionCall: name + arguments + call_id
177
+ if (typeof it['name'] === 'string' && typeof it['call_id'] === 'string' && typeof it['arguments'] === 'string') {
178
+ const call = it;
179
+ let parsedArgs = {};
180
+ try {
181
+ const a = JSON.parse(call.arguments);
182
+ if (typeof a === 'object' && a !== null && !Array.isArray(a)) {
183
+ parsedArgs = a;
184
+ }
185
+ }
186
+ catch { /* leave empty */ }
187
+ const tokens = latestTurnId ? (turnTokens.get(latestTurnId) ?? latestTokenData) : latestTokenData;
188
+ const contextWindow = tokens?.contextWindow ?? 128000;
189
+ const fillPct = tokens ? (tokens.inputTokens / contextWindow) * 100 : 0;
190
+ const toolLabel = buildLabel(call.name, parsedArgs);
191
+ const row = {
192
+ id: call.call_id,
193
+ type: 'tool',
194
+ toolName: call.name === 'shell' ? 'Bash' : call.name,
195
+ label: toolLabel,
196
+ startTime: timestamp,
197
+ endTime: null,
198
+ duration: null,
199
+ status: 'running',
200
+ parentAgentId: null,
201
+ input: parsedArgs,
202
+ output: null,
203
+ inputTokens: tokens?.inputTokens ?? 0,
204
+ outputTokens: tokens?.outputTokens ?? 0,
205
+ tokenDelta: 0,
206
+ contextFillPercent: fillPct,
207
+ isReread: false,
208
+ isFailure: false,
209
+ children: [],
210
+ tips: [],
211
+ modelName: null,
212
+ estimatedCost: null,
213
+ agentType: null,
214
+ agentColor: null,
215
+ sequence: sequence++,
216
+ isFastMode: false,
217
+ parentToolUseId: null,
218
+ };
219
+ rows.push(row);
220
+ callMap.set(call.call_id, { row, timestamp });
221
+ continue;
222
+ }
223
+ }
224
+ }
225
+ // Any tool calls still in callMap have no matching output (session truncated / running)
226
+ // Leave them as status: 'running'
227
+ return rows;
228
+ }
229
+ /** Build a human-readable label for a tool call. */
230
+ function buildLabel(toolName, args) {
231
+ const displayName = toolName === 'shell' ? 'Bash' : toolName;
232
+ if (toolName === 'shell') {
233
+ const cmd = typeof args['command'] === 'string' ? args['command'] : '';
234
+ const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
235
+ return `Bash: ${short}`;
236
+ }
237
+ const first = Object.values(args)[0];
238
+ if (typeof first === 'string') {
239
+ const short = first.length > 60 ? first.slice(0, 57) + '...' : first;
240
+ return `${displayName}: ${short}`;
241
+ }
242
+ return displayName;
243
+ }
244
+ /** Extract the session-level metadata record from parsed lines. */
245
+ function extractCodexSessionMeta(lines) {
246
+ for (const line of lines) {
247
+ const rec = parseLine(line);
248
+ if (rec?.['type'] === 'SessionMeta')
249
+ return rec;
250
+ }
251
+ return null;
252
+ }
253
+ // ---------------------------------------------------------------------------
254
+ // listSessions helpers
255
+ // ---------------------------------------------------------------------------
256
+ /** Recursively collect all .jsonl rollout files under a directory. */
257
+ async function collectRolloutFiles(dir) {
258
+ const results = [];
259
+ let names;
260
+ try {
261
+ names = await fs.readdir(dir);
262
+ }
263
+ catch {
264
+ return results;
265
+ }
266
+ for (const name of names) {
267
+ const full = path.join(dir, name);
268
+ let stat;
269
+ try {
270
+ stat = await fs.stat(full);
271
+ }
272
+ catch {
273
+ continue;
274
+ }
275
+ if (stat.isDirectory()) {
276
+ const sub = await collectRolloutFiles(full);
277
+ results.push(...sub);
278
+ }
279
+ else if (stat.isFile() && name.endsWith('.jsonl') && name.startsWith('rollout-')) {
280
+ results.push(full);
281
+ }
282
+ }
283
+ return results;
284
+ }
285
+ // ---------------------------------------------------------------------------
286
+ // Provider factory
287
+ // ---------------------------------------------------------------------------
288
+ /**
289
+ * Create a Codex CLI provider instance.
290
+ *
291
+ * @param codexHome - Override path to the Codex home directory.
292
+ * Defaults to CODEX_HOME env var or ~/.codex.
293
+ */
294
+ export function createCodexProvider(codexHome) {
295
+ const home = resolveCodexHome(codexHome);
296
+ const sessionsDir = path.join(home, 'sessions');
297
+ return {
298
+ id: 'codex',
299
+ displayName: 'Codex CLI',
300
+ capabilities: CODEX_CAPABILITIES,
301
+ async listSessions(window) {
302
+ const results = [];
303
+ const files = await collectRolloutFiles(sessionsDir);
304
+ for (const filePath of files) {
305
+ const mtime = await safeStatMtime(filePath);
306
+ if (!mtime)
307
+ continue;
308
+ const mtimeMs = mtime.getTime();
309
+ if (mtimeMs < window.startMs || mtimeMs >= window.endMs)
310
+ continue;
311
+ // rawSlug: path relative to sessionsDir
312
+ const rawSlug = path.relative(sessionsDir, filePath).split(path.sep).join('/');
313
+ // Read first 2048 bytes to get SessionMeta without loading full file
314
+ let metaRecord = null;
315
+ try {
316
+ const fh = await fs.open(filePath, 'r');
317
+ try {
318
+ const buf = Buffer.alloc(2048);
319
+ const { bytesRead } = await fh.read(buf, 0, 2048, 0);
320
+ const chunk = buf.slice(0, bytesRead).toString('utf8');
321
+ metaRecord = extractCodexSessionMeta(chunk.split('\n'));
322
+ }
323
+ finally {
324
+ await fh.close();
325
+ }
326
+ }
327
+ catch { /* leave metaRecord as null */ }
328
+ const startMs = metaRecord?.timestamp
329
+ ? new Date(metaRecord.timestamp).getTime()
330
+ : mtimeMs;
331
+ const cwd = metaRecord?.cwd ?? null;
332
+ const sessionId = metaRecord?.id ?? rawSlug;
333
+ const forkedFrom = metaRecord?.forked_from_id ?? null;
334
+ const meta = {
335
+ provider: 'codex',
336
+ sessionId,
337
+ projectContext: toProjectContext(cwd),
338
+ rawSlug,
339
+ startMs,
340
+ endMs: mtimeMs,
341
+ ...(metaRecord?.model_provider ? { modelHint: metaRecord.model_provider } : {}),
342
+ ...(forkedFrom ? { parentSessionId: forkedFrom } : {}),
343
+ };
344
+ results.push(meta);
345
+ }
346
+ return results;
347
+ },
348
+ async readSession(id) {
349
+ // id is the rawSlug: relative path from sessions/, e.g. '2026/04/15/rollout-....jsonl'
350
+ const filePath = path.join(sessionsDir, ...id.split('/'));
351
+ let content;
352
+ try {
353
+ content = await fs.readFile(filePath, 'utf8');
354
+ }
355
+ catch {
356
+ throw new Error(`Codex session not found: ${id}`);
357
+ }
358
+ const lines = content.split('\n');
359
+ const metaRecord = extractCodexSessionMeta(lines);
360
+ const mtime = await safeStatMtime(filePath);
361
+ const startMs = metaRecord?.timestamp
362
+ ? new Date(metaRecord.timestamp).getTime()
363
+ : (mtime?.getTime() ?? Date.now());
364
+ const cwd = metaRecord?.cwd ?? null;
365
+ const sessionId = metaRecord?.id ?? id;
366
+ const forkedFrom = metaRecord?.forked_from_id ?? null;
367
+ const meta = {
368
+ provider: 'codex',
369
+ sessionId,
370
+ projectContext: toProjectContext(cwd),
371
+ rawSlug: id,
372
+ startMs,
373
+ endMs: mtime?.getTime() ?? null,
374
+ ...(metaRecord?.model_provider ? { modelHint: metaRecord.model_provider } : {}),
375
+ ...(forkedFrom ? { parentSessionId: forkedFrom } : {}),
376
+ };
377
+ const rows = parseCodexContent(content);
378
+ return { meta, native: rows };
379
+ },
380
+ watch(onEvent) {
381
+ let watcher = null;
382
+ try {
383
+ watcher = chokidar.watch(sessionsDir, {
384
+ persistent: true,
385
+ ignoreInitial: true,
386
+ depth: 4,
387
+ });
388
+ watcher.on('add', (filePath) => {
389
+ if (!filePath.endsWith('.jsonl'))
390
+ return;
391
+ const rawSlug = path.relative(sessionsDir, filePath).split(path.sep).join('/');
392
+ onEvent({ kind: 'session-added', provider: 'codex', sessionId: rawSlug });
393
+ });
394
+ watcher.on('change', (filePath) => {
395
+ if (!filePath.endsWith('.jsonl'))
396
+ return;
397
+ const rawSlug = path.relative(sessionsDir, filePath).split(path.sep).join('/');
398
+ onEvent({ kind: 'session-updated', provider: 'codex', sessionId: rawSlug });
399
+ });
400
+ watcher.on('unlink', (filePath) => {
401
+ if (!filePath.endsWith('.jsonl'))
402
+ return;
403
+ const rawSlug = path.relative(sessionsDir, filePath).split(path.sep).join('/');
404
+ onEvent({ kind: 'session-removed', provider: 'codex', sessionId: rawSlug });
405
+ });
406
+ watcher.on('error', (err) => {
407
+ console.warn('[noctrace] codex provider watcher error:', err instanceof Error ? err.message : String(err));
408
+ });
409
+ }
410
+ catch (err) {
411
+ console.warn('[noctrace] codex provider: could not start watcher:', err instanceof Error ? err.message : String(err));
412
+ }
413
+ return () => {
414
+ watcher?.close().catch(() => { });
415
+ };
416
+ },
417
+ };
418
+ }
@@ -4,6 +4,7 @@
4
4
  * Additional providers (Codex, Copilot, etc.) will be registered in Phase B/C.
5
5
  */
6
6
  import { createClaudeCodeProvider } from './claude-code.js';
7
+ import { createCodexProvider } from './codex.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Registry
9
10
  // ---------------------------------------------------------------------------
@@ -35,3 +36,6 @@ export function listProviders() {
35
36
  // Register the Claude Code provider with default settings.
36
37
  // The claudeHome path is resolved from CLAUDE_HOME env var or ~/.claude.
37
38
  registerProvider(createClaudeCodeProvider());
39
+ // Register the Codex CLI provider.
40
+ // The codexHome path is resolved from CODEX_HOME env var or ~/.codex.
41
+ registerProvider(createCodexProvider());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Claude Code observability — DevTools-style waterfall visualizer for AI agent workflows, token tracking, and context health monitoring",
5
5
  "type": "module",
6
6
  "license": "MIT",