noctrace 1.2.0 → 1.4.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/.claude-plugin/plugin.json +1 -1
- package/dist/client/assets/index-BGW0xA7n.js +30 -0
- package/dist/client/assets/index-DlKrxvV-.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/rollup.js +64 -70
- package/dist/server/server/routes/api.js +30 -9
- package/dist/server/server/ws.js +16 -19
- package/dist/server/shared/providers/claude-code.js +268 -0
- package/dist/server/shared/providers/codex.js +418 -0
- package/dist/server/shared/providers/index.js +41 -0
- package/dist/server/shared/providers/provider.js +5 -0
- package/dist/server/shared/session.js +6 -0
- package/package.json +3 -1
- package/dist/client/assets/index-C0dmCjnv.js +0 -30
- package/dist/client/assets/index-DYum71Dc.css +0 -2
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code provider implementation.
|
|
3
|
+
* Wraps the existing parseJsonlContent / parseSubAgentContent logic and the
|
|
4
|
+
* ~/.claude/projects directory structure into the Provider interface.
|
|
5
|
+
*
|
|
6
|
+
* Session id format: '<projectSlug>/<sessionId>'
|
|
7
|
+
* e.g. '-Users-lam-dev-noctrace/abc123def456'
|
|
8
|
+
*
|
|
9
|
+
* Phase A note: watch() returns a no-op unsubscribe. Real-time chokidar
|
|
10
|
+
* integration is deferred to Phase B when the server wires it up.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import chokidar from 'chokidar';
|
|
16
|
+
import { parseJsonlContent, extractSessionId } from '../parser.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the Claude home directory.
|
|
22
|
+
* Prefers the CLAUDE_HOME environment variable, falls back to ~/.claude.
|
|
23
|
+
*/
|
|
24
|
+
function resolveClaudeHome(override) {
|
|
25
|
+
if (override)
|
|
26
|
+
return override;
|
|
27
|
+
return process.env['CLAUDE_HOME'] ?? path.join(os.homedir(), '.claude');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Convert a project directory slug back to a human-readable path.
|
|
31
|
+
* '-Users-lam-dev-noctrace' → '/Users/lam/dev/noctrace'
|
|
32
|
+
* The result is then replaced with '~' when it starts with the home directory.
|
|
33
|
+
*/
|
|
34
|
+
function deSlugifyProject(slug) {
|
|
35
|
+
const rawPath = slug.replace(/-/g, '/');
|
|
36
|
+
const home = os.homedir();
|
|
37
|
+
if (rawPath.startsWith(home)) {
|
|
38
|
+
return '~' + rawPath.slice(home.length);
|
|
39
|
+
}
|
|
40
|
+
return rawPath;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read the mtime of a file; returns null on error.
|
|
44
|
+
*/
|
|
45
|
+
async function safeStatMtime(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
const stat = await fs.stat(filePath);
|
|
48
|
+
return stat.mtime;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Provider factory
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const CLAUDE_CODE_CAPABILITIES = {
|
|
58
|
+
toolCallGranularity: 'full',
|
|
59
|
+
contextTracking: true,
|
|
60
|
+
subAgents: true,
|
|
61
|
+
realtime: true,
|
|
62
|
+
tokenAccounting: 'per-turn',
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Create a Claude Code provider instance.
|
|
66
|
+
*
|
|
67
|
+
* @param claudeHome - Override path to the Claude home directory.
|
|
68
|
+
* Defaults to CLAUDE_HOME env var or ~/.claude.
|
|
69
|
+
*/
|
|
70
|
+
export function createClaudeCodeProvider(claudeHome) {
|
|
71
|
+
const home = resolveClaudeHome(claudeHome);
|
|
72
|
+
const projectsDir = path.join(home, 'projects');
|
|
73
|
+
return {
|
|
74
|
+
id: 'claude-code',
|
|
75
|
+
displayName: 'Claude Code',
|
|
76
|
+
capabilities: CLAUDE_CODE_CAPABILITIES,
|
|
77
|
+
async listSessions(window) {
|
|
78
|
+
const results = [];
|
|
79
|
+
let slugs;
|
|
80
|
+
try {
|
|
81
|
+
slugs = await fs.readdir(projectsDir);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Projects directory doesn't exist — return empty list gracefully
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
for (const slug of slugs) {
|
|
88
|
+
const projectDir = path.join(projectsDir, slug);
|
|
89
|
+
let dirStat;
|
|
90
|
+
try {
|
|
91
|
+
dirStat = await fs.stat(projectDir);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!dirStat.isDirectory())
|
|
97
|
+
continue;
|
|
98
|
+
let files;
|
|
99
|
+
try {
|
|
100
|
+
files = await fs.readdir(projectDir);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
if (!file.endsWith('.jsonl'))
|
|
107
|
+
continue;
|
|
108
|
+
const sessionId = file.replace(/\.jsonl$/, '');
|
|
109
|
+
const filePath = path.join(projectDir, file);
|
|
110
|
+
const mtime = await safeStatMtime(filePath);
|
|
111
|
+
if (!mtime)
|
|
112
|
+
continue;
|
|
113
|
+
const mtimeMs = mtime.getTime();
|
|
114
|
+
// Filter by window: use mtime as endMs heuristic
|
|
115
|
+
if (mtimeMs < window.startMs || mtimeMs >= window.endMs)
|
|
116
|
+
continue;
|
|
117
|
+
// Extract start time from first record (best-effort, fall back to mtime)
|
|
118
|
+
let startMs = mtimeMs;
|
|
119
|
+
try {
|
|
120
|
+
const firstChunk = await readFirstChunk(filePath, 4096);
|
|
121
|
+
const firstTs = extractFirstTimestamp(firstChunk);
|
|
122
|
+
if (firstTs !== null)
|
|
123
|
+
startMs = firstTs;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Leave startMs as mtime
|
|
127
|
+
}
|
|
128
|
+
results.push({
|
|
129
|
+
provider: 'claude-code',
|
|
130
|
+
sessionId,
|
|
131
|
+
projectContext: deSlugifyProject(slug),
|
|
132
|
+
rawSlug: `${slug}/${sessionId}`,
|
|
133
|
+
startMs,
|
|
134
|
+
endMs: mtimeMs,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
},
|
|
140
|
+
async readSession(id) {
|
|
141
|
+
// id format: '<projectSlug>/<sessionId>'
|
|
142
|
+
const slashIdx = id.indexOf('/');
|
|
143
|
+
if (slashIdx === -1) {
|
|
144
|
+
throw new Error(`Invalid Claude Code session id: "${id}". Expected "<projectSlug>/<sessionId>".`);
|
|
145
|
+
}
|
|
146
|
+
const projectSlug = id.slice(0, slashIdx);
|
|
147
|
+
const sessionId = id.slice(slashIdx + 1);
|
|
148
|
+
const filePath = path.join(projectsDir, projectSlug, `${sessionId}.jsonl`);
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error(`Claude Code session not found: ${id}`);
|
|
155
|
+
}
|
|
156
|
+
const rows = parseJsonlContent(content);
|
|
157
|
+
const canonicalSessionId = extractSessionId(content) ?? sessionId;
|
|
158
|
+
// Extract mtime for endMs
|
|
159
|
+
const mtime = await safeStatMtime(filePath);
|
|
160
|
+
// Extract start time
|
|
161
|
+
const firstTs = extractFirstTimestamp(content.slice(0, 4096));
|
|
162
|
+
const startMs = firstTs ?? (mtime?.getTime() ?? Date.now());
|
|
163
|
+
const meta = {
|
|
164
|
+
provider: 'claude-code',
|
|
165
|
+
sessionId: canonicalSessionId,
|
|
166
|
+
projectContext: deSlugifyProject(projectSlug),
|
|
167
|
+
rawSlug: `${projectSlug}/${sessionId}`,
|
|
168
|
+
startMs,
|
|
169
|
+
endMs: mtime?.getTime() ?? null,
|
|
170
|
+
};
|
|
171
|
+
return { meta, native: rows };
|
|
172
|
+
},
|
|
173
|
+
watch(onEvent) {
|
|
174
|
+
// Phase B: real chokidar integration.
|
|
175
|
+
// Watches the projects directory for added and changed .jsonl files.
|
|
176
|
+
// Emits session-added for new files and session-updated for changed files.
|
|
177
|
+
// Uses persistent:true, ignoreInitial:true per architecture constraints.
|
|
178
|
+
let watcher = null;
|
|
179
|
+
try {
|
|
180
|
+
watcher = chokidar.watch(projectsDir, {
|
|
181
|
+
persistent: true,
|
|
182
|
+
ignoreInitial: true,
|
|
183
|
+
depth: 2,
|
|
184
|
+
});
|
|
185
|
+
watcher.on('add', (filePath) => {
|
|
186
|
+
if (!filePath.endsWith('.jsonl'))
|
|
187
|
+
return;
|
|
188
|
+
const relative = path.relative(projectsDir, filePath);
|
|
189
|
+
const parts = relative.split(path.sep);
|
|
190
|
+
if (parts.length < 2)
|
|
191
|
+
return;
|
|
192
|
+
const slug = parts[0];
|
|
193
|
+
const sessionId = parts[1].replace(/\.jsonl$/, '');
|
|
194
|
+
const id = `${slug}/${sessionId}`;
|
|
195
|
+
onEvent({ kind: 'session-added', provider: 'claude-code', sessionId: id });
|
|
196
|
+
});
|
|
197
|
+
watcher.on('change', (filePath) => {
|
|
198
|
+
if (!filePath.endsWith('.jsonl'))
|
|
199
|
+
return;
|
|
200
|
+
const relative = path.relative(projectsDir, filePath);
|
|
201
|
+
const parts = relative.split(path.sep);
|
|
202
|
+
if (parts.length < 2)
|
|
203
|
+
return;
|
|
204
|
+
const slug = parts[0];
|
|
205
|
+
const sessionId = parts[1].replace(/\.jsonl$/, '');
|
|
206
|
+
const id = `${slug}/${sessionId}`;
|
|
207
|
+
onEvent({ kind: 'session-updated', provider: 'claude-code', sessionId: id });
|
|
208
|
+
});
|
|
209
|
+
watcher.on('error', (err) => {
|
|
210
|
+
console.warn('[noctrace] claude-code provider watcher error:', err instanceof Error ? err.message : String(err));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
// If the projects directory doesn't exist, chokidar may throw — degrade gracefully
|
|
215
|
+
console.warn('[noctrace] claude-code provider: could not start watcher:', err instanceof Error ? err.message : String(err));
|
|
216
|
+
}
|
|
217
|
+
return () => {
|
|
218
|
+
watcher?.close().catch(() => { });
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Internal helpers
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
/**
|
|
227
|
+
* Read the first `maxBytes` of a file as a UTF-8 string.
|
|
228
|
+
* Used for fast timestamp extraction without loading the full file.
|
|
229
|
+
*/
|
|
230
|
+
async function readFirstChunk(filePath, maxBytes) {
|
|
231
|
+
const fh = await fs.open(filePath, 'r');
|
|
232
|
+
try {
|
|
233
|
+
const buf = Buffer.alloc(maxBytes);
|
|
234
|
+
const { bytesRead } = await fh.read(buf, 0, maxBytes, 0);
|
|
235
|
+
return buf.slice(0, bytesRead).toString('utf8');
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await fh.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Extract the Unix-ms timestamp from the first valid JSON record in a string.
|
|
243
|
+
* Returns null when no timestamp can be found.
|
|
244
|
+
*/
|
|
245
|
+
function extractFirstTimestamp(chunk) {
|
|
246
|
+
const lines = chunk.split('\n');
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const t = line.trim();
|
|
249
|
+
if (!t)
|
|
250
|
+
continue;
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(t);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
259
|
+
continue;
|
|
260
|
+
const ts = parsed['timestamp'];
|
|
261
|
+
if (typeof ts === 'string') {
|
|
262
|
+
const ms = new Date(ts).getTime();
|
|
263
|
+
if (!isNaN(ms))
|
|
264
|
+
return ms;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
@@ -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
|
+
}
|