zozul-cli 0.3.6 → 0.3.8
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/settings.local.json +4 -1
- package/dist/dashboard/index.html +32 -8
- package/dist/hooks/server.js +6 -0
- package/dist/hooks/server.js.map +1 -1
- package/dist/parser/ingest.d.ts +2 -0
- package/dist/parser/ingest.d.ts.map +1 -1
- package/dist/parser/ingest.js +8 -3
- package/dist/parser/ingest.js.map +1 -1
- package/dist/parser/jsonl.d.ts +9 -3
- package/dist/parser/jsonl.d.ts.map +1 -1
- package/dist/parser/jsonl.js +35 -11
- package/dist/parser/jsonl.js.map +1 -1
- package/dist/parser/types.d.ts +2 -0
- package/dist/parser/types.d.ts.map +1 -1
- package/dist/parser/watcher.d.ts.map +1 -1
- package/dist/parser/watcher.js +37 -10
- package/dist/parser/watcher.js.map +1 -1
- package/dist/storage/db.d.ts +2 -0
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +12 -0
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/repo.d.ts +1 -0
- package/dist/storage/repo.d.ts.map +1 -1
- package/dist/storage/repo.js +34 -21
- package/dist/storage/repo.js.map +1 -1
- package/dist/sync/sync.test.js +12 -0
- package/dist/sync/sync.test.js.map +1 -1
- package/dist/sync/transform.d.ts +2 -0
- package/dist/sync/transform.d.ts.map +1 -1
- package/dist/sync/transform.js +2 -0
- package/dist/sync/transform.js.map +1 -1
- package/package.json +1 -1
- package/src/dashboard/index.html +32 -8
- package/src/hooks/server.ts +7 -0
- package/src/parser/ingest.ts +10 -5
- package/src/parser/jsonl.ts +43 -7
- package/src/parser/types.ts +2 -0
- package/src/parser/watcher.ts +34 -9
- package/src/storage/db.ts +12 -0
- package/src/storage/repo.ts +35 -21
- package/src/sync/sync.test.ts +12 -0
- package/src/sync/transform.ts +4 -0
package/src/parser/watcher.ts
CHANGED
|
@@ -30,9 +30,9 @@ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => voi
|
|
|
30
30
|
if (catchUp) {
|
|
31
31
|
const files = discoverSessionFiles();
|
|
32
32
|
let caught = 0;
|
|
33
|
-
for (const { filePath, projectPath } of files) {
|
|
33
|
+
for (const { filePath, projectPath, parentSessionId, agentType } of files) {
|
|
34
34
|
try {
|
|
35
|
-
await ingestSessionFile(repo, filePath, projectPath);
|
|
35
|
+
await ingestSessionFile(repo, filePath, projectPath, { parentSessionId, agentType });
|
|
36
36
|
caught++;
|
|
37
37
|
} catch {
|
|
38
38
|
// Ignore parse errors on individual files
|
|
@@ -61,7 +61,11 @@ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => voi
|
|
|
61
61
|
timers.delete(filePath);
|
|
62
62
|
try {
|
|
63
63
|
const projectPath = decodeProjectPath(filePath);
|
|
64
|
-
|
|
64
|
+
const subagentInfo = extractSubagentInfo(filePath);
|
|
65
|
+
await ingestSessionFile(repo, filePath, projectPath ?? undefined, {
|
|
66
|
+
parentSessionId: subagentInfo?.parentSessionId,
|
|
67
|
+
agentType: subagentInfo?.agentType,
|
|
68
|
+
});
|
|
65
69
|
if (verbose) {
|
|
66
70
|
process.stderr.write(`[watcher] ingested: ${filePath}\n`);
|
|
67
71
|
}
|
|
@@ -103,14 +107,35 @@ export async function watchSessionFiles(opts: WatcherOptions): Promise<() => voi
|
|
|
103
107
|
};
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Extract parent session ID and agent type from a subagent file path.
|
|
112
|
+
* Path format: .../<parent-uuid>/subagents/<agent-id>.jsonl
|
|
113
|
+
*/
|
|
114
|
+
function extractSubagentInfo(filePath: string): { parentSessionId: string; agentType: string | undefined } | null {
|
|
115
|
+
const match = filePath.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/subagents\/([^/]+)\.jsonl$/i);
|
|
116
|
+
if (!match) return null;
|
|
117
|
+
const parentSessionId = match[1];
|
|
118
|
+
let agentType: string | undefined;
|
|
119
|
+
try {
|
|
120
|
+
const metaPath = filePath.replace(".jsonl", ".meta.json");
|
|
121
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
|
122
|
+
agentType = meta.agentType;
|
|
123
|
+
} catch { /* missing or invalid meta */ }
|
|
124
|
+
return { parentSessionId, agentType };
|
|
125
|
+
}
|
|
126
|
+
|
|
106
127
|
/**
|
|
107
128
|
* Extract the decoded project path from an absolute JSONL file path.
|
|
108
|
-
*
|
|
109
|
-
*
|
|
129
|
+
* Handles both main sessions and subagent files:
|
|
130
|
+
* ~/.claude/projects/<encoded>/<uuid>.jsonl
|
|
131
|
+
* ~/.claude/projects/<encoded>/<uuid>/subagents/<agent-id>.jsonl
|
|
110
132
|
*/
|
|
111
133
|
function decodeProjectPath(filePath: string): string | null {
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
134
|
+
// Main session: projects/<encoded>/<uuid>.jsonl
|
|
135
|
+
const parentMatch = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
|
|
136
|
+
if (parentMatch) return parentMatch[1].replace(/-/g, "/");
|
|
137
|
+
// Subagent: projects/<encoded>/<uuid>/subagents/<agent-id>.jsonl
|
|
138
|
+
const subMatch = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\/subagents\/[^/]+\.jsonl$/i);
|
|
139
|
+
if (subMatch) return subMatch[1].replace(/-/g, "/");
|
|
140
|
+
return null;
|
|
116
141
|
}
|
package/src/storage/db.ts
CHANGED
|
@@ -116,11 +116,23 @@ function migrate(db: Database.Database): void {
|
|
|
116
116
|
last_synced_at TEXT
|
|
117
117
|
);
|
|
118
118
|
`);
|
|
119
|
+
|
|
120
|
+
// Additive migrations (safe to re-run; errors mean column already exists)
|
|
121
|
+
const addColumns = [
|
|
122
|
+
`ALTER TABLE sessions ADD COLUMN parent_session_id TEXT`,
|
|
123
|
+
`ALTER TABLE sessions ADD COLUMN agent_type TEXT`,
|
|
124
|
+
];
|
|
125
|
+
for (const sql of addColumns) {
|
|
126
|
+
try { db.exec(sql); } catch { /* column already exists */ }
|
|
127
|
+
}
|
|
128
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id)`);
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
export type SessionRow = {
|
|
122
132
|
id: string;
|
|
123
133
|
project_path: string | null;
|
|
134
|
+
parent_session_id: string | null;
|
|
135
|
+
agent_type: string | null;
|
|
124
136
|
started_at: string;
|
|
125
137
|
ended_at: string | null;
|
|
126
138
|
total_input_tokens: number;
|
package/src/storage/repo.ts
CHANGED
|
@@ -22,27 +22,33 @@ export class SessionRepo {
|
|
|
22
22
|
|
|
23
23
|
upsertSession(session: Omit<SessionRow, "ended_at"> & { ended_at?: string | null }): void {
|
|
24
24
|
this.db.prepare(`
|
|
25
|
-
INSERT INTO sessions (id, project_path, started_at, ended_at,
|
|
26
|
-
total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
|
|
25
|
+
INSERT INTO sessions (id, project_path, parent_session_id, agent_type, started_at, ended_at,
|
|
26
|
+
total_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
|
|
27
27
|
total_cost_usd, total_turns, total_duration_ms, model)
|
|
28
|
-
VALUES (@id, @project_path, @
|
|
29
|
-
@total_output_tokens, @total_cache_read_tokens, @total_cache_creation_tokens,
|
|
28
|
+
VALUES (@id, @project_path, @parent_session_id, @agent_type, @started_at, @ended_at,
|
|
29
|
+
@total_input_tokens, @total_output_tokens, @total_cache_read_tokens, @total_cache_creation_tokens,
|
|
30
30
|
@total_cost_usd, @total_turns, @total_duration_ms, @model)
|
|
31
31
|
ON CONFLICT(id) DO UPDATE SET
|
|
32
32
|
started_at = MIN(started_at, @started_at),
|
|
33
33
|
project_path = COALESCE(@project_path, project_path),
|
|
34
|
+
parent_session_id = COALESCE(@parent_session_id, parent_session_id),
|
|
35
|
+
agent_type = COALESCE(@agent_type, agent_type),
|
|
34
36
|
ended_at = COALESCE(@ended_at, ended_at),
|
|
35
37
|
total_turns = @total_turns,
|
|
36
38
|
model = COALESCE(@model, model),
|
|
37
|
-
-- Use MAX
|
|
38
|
-
--
|
|
39
|
+
-- Use MAX so OTEL values aren't clobbered by JSONL re-ingest,
|
|
40
|
+
-- but JSONL values fill in as a floor for sessions without OTEL data.
|
|
39
41
|
total_input_tokens = MAX(total_input_tokens, @total_input_tokens),
|
|
40
42
|
total_output_tokens = MAX(total_output_tokens, @total_output_tokens),
|
|
41
43
|
total_cache_read_tokens = MAX(total_cache_read_tokens, @total_cache_read_tokens),
|
|
42
|
-
total_cache_creation_tokens = MAX(total_cache_creation_tokens, @total_cache_creation_tokens)
|
|
44
|
+
total_cache_creation_tokens = MAX(total_cache_creation_tokens, @total_cache_creation_tokens),
|
|
45
|
+
total_cost_usd = MAX(total_cost_usd, @total_cost_usd),
|
|
46
|
+
total_duration_ms = MAX(total_duration_ms, @total_duration_ms)
|
|
43
47
|
`).run({
|
|
44
48
|
...session,
|
|
45
49
|
ended_at: session.ended_at ?? null,
|
|
50
|
+
parent_session_id: session.parent_session_id ?? null,
|
|
51
|
+
agent_type: session.agent_type ?? null,
|
|
46
52
|
});
|
|
47
53
|
}
|
|
48
54
|
|
|
@@ -177,20 +183,20 @@ export class SessionRepo {
|
|
|
177
183
|
listSessions(limit = 50, offset = 0, from?: string, to?: string): SessionRow[] {
|
|
178
184
|
if (from && to) {
|
|
179
185
|
return this.db.prepare(`
|
|
180
|
-
SELECT * FROM sessions WHERE started_at >= ? AND started_at <= ? ORDER BY started_at DESC LIMIT ? OFFSET ?
|
|
186
|
+
SELECT * FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? AND started_at <= ? ORDER BY started_at DESC LIMIT ? OFFSET ?
|
|
181
187
|
`).all(from, to, limit, offset) as SessionRow[];
|
|
182
188
|
}
|
|
183
189
|
return this.db.prepare(`
|
|
184
|
-
SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?
|
|
190
|
+
SELECT * FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ? OFFSET ?
|
|
185
191
|
`).all(limit, offset) as SessionRow[];
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
countSessions(from?: string, to?: string): number {
|
|
189
195
|
if (from && to) {
|
|
190
|
-
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions WHERE started_at >= ? AND started_at <= ?`).get(from, to) as { n: number };
|
|
196
|
+
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? AND started_at <= ?`).get(from, to) as { n: number };
|
|
191
197
|
return row.n;
|
|
192
198
|
}
|
|
193
|
-
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions`).get() as { n: number };
|
|
199
|
+
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions WHERE parent_session_id IS NULL`).get() as { n: number };
|
|
194
200
|
return row.n;
|
|
195
201
|
}
|
|
196
202
|
|
|
@@ -198,6 +204,12 @@ export class SessionRepo {
|
|
|
198
204
|
return this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as SessionRow | undefined;
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
getSubSessions(parentSessionId: string): SessionRow[] {
|
|
208
|
+
return this.db.prepare(`
|
|
209
|
+
SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC
|
|
210
|
+
`).all(parentSessionId) as SessionRow[];
|
|
211
|
+
}
|
|
212
|
+
|
|
201
213
|
getSessionTurns(sessionId: string): TurnRow[] {
|
|
202
214
|
return this.db.prepare(`
|
|
203
215
|
SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index ASC
|
|
@@ -219,7 +231,7 @@ export class SessionRepo {
|
|
|
219
231
|
getAggregateStats(from?: string, to?: string) {
|
|
220
232
|
if (from && to) {
|
|
221
233
|
return this.db.prepare(`
|
|
222
|
-
WITH fs AS (SELECT * FROM sessions WHERE started_at >= ? AND started_at <= ?),
|
|
234
|
+
WITH fs AS (SELECT * FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? AND started_at <= ?),
|
|
223
235
|
fh AS (SELECT * FROM hook_events WHERE timestamp >= ? AND timestamp <= ?)
|
|
224
236
|
SELECT
|
|
225
237
|
(SELECT COUNT(*) FROM fs) as total_sessions,
|
|
@@ -235,13 +247,13 @@ export class SessionRepo {
|
|
|
235
247
|
}
|
|
236
248
|
return this.db.prepare(`
|
|
237
249
|
SELECT
|
|
238
|
-
(SELECT COUNT(*) FROM sessions) as total_sessions,
|
|
239
|
-
(SELECT SUM(total_input_tokens) FROM sessions) as total_input_tokens,
|
|
240
|
-
(SELECT SUM(total_output_tokens) FROM sessions) as total_output_tokens,
|
|
241
|
-
(SELECT SUM(total_cache_read_tokens) FROM sessions) as total_cache_read_tokens,
|
|
242
|
-
(SELECT SUM(total_cost_usd) FROM sessions) as total_cost_usd,
|
|
243
|
-
(SELECT SUM(total_turns) FROM sessions) as total_turns,
|
|
244
|
-
(SELECT SUM(total_duration_ms) FROM sessions) as total_duration_ms,
|
|
250
|
+
(SELECT COUNT(*) FROM sessions WHERE parent_session_id IS NULL) as total_sessions,
|
|
251
|
+
(SELECT SUM(total_input_tokens) FROM sessions WHERE parent_session_id IS NULL) as total_input_tokens,
|
|
252
|
+
(SELECT SUM(total_output_tokens) FROM sessions WHERE parent_session_id IS NULL) as total_output_tokens,
|
|
253
|
+
(SELECT SUM(total_cache_read_tokens) FROM sessions WHERE parent_session_id IS NULL) as total_cache_read_tokens,
|
|
254
|
+
(SELECT SUM(total_cost_usd) FROM sessions WHERE parent_session_id IS NULL) as total_cost_usd,
|
|
255
|
+
(SELECT SUM(total_turns) FROM sessions WHERE parent_session_id IS NULL) as total_turns,
|
|
256
|
+
(SELECT SUM(total_duration_ms) FROM sessions WHERE parent_session_id IS NULL) as total_duration_ms,
|
|
245
257
|
(SELECT COUNT(*) FROM hook_events WHERE event_name = 'UserPromptSubmit') as total_user_prompts,
|
|
246
258
|
(SELECT COUNT(*) FROM hook_events WHERE event_name = 'Stop') as total_interruptions
|
|
247
259
|
`).get();
|
|
@@ -476,6 +488,7 @@ export class SessionRepo {
|
|
|
476
488
|
|
|
477
489
|
// Only show real user turns (each row = one interaction block)
|
|
478
490
|
conditions.push("t.is_real_user = 1");
|
|
491
|
+
conditions.push("t.session_id IN (SELECT id FROM sessions WHERE parent_session_id IS NULL)");
|
|
479
492
|
|
|
480
493
|
const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
|
|
481
494
|
params.push(limit, offset);
|
|
@@ -600,7 +613,7 @@ export class SessionRepo {
|
|
|
600
613
|
* Safe to call repeatedly — always produces correct values from append-only source data.
|
|
601
614
|
*/
|
|
602
615
|
getTaskGroups(from?: string, to?: string): { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; last_seen: string }[] {
|
|
603
|
-
const timeFilter = from && to ? "
|
|
616
|
+
const timeFilter = from && to ? " AND t.timestamp >= ? AND t.timestamp <= ?" : "";
|
|
604
617
|
const params: unknown[] = from && to ? [from, to] : [];
|
|
605
618
|
|
|
606
619
|
return this.db.prepare(`
|
|
@@ -618,7 +631,8 @@ export class SessionRepo {
|
|
|
618
631
|
MAX(t.timestamp) as last_seen
|
|
619
632
|
FROM turns t
|
|
620
633
|
LEFT JOIN turn_tag_sets tts ON tts.turn_id = t.id
|
|
621
|
-
JOIN sessions s ON s.id = t.session_id
|
|
634
|
+
JOIN sessions s ON s.id = t.session_id
|
|
635
|
+
WHERE s.parent_session_id IS NULL${timeFilter}
|
|
622
636
|
GROUP BY COALESCE(tts.tag_set, 'Untagged')
|
|
623
637
|
ORDER BY last_seen DESC
|
|
624
638
|
`).all(...params) as { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; last_seen: string }[];
|
package/src/sync/sync.test.ts
CHANGED
|
@@ -104,6 +104,8 @@ describe("zozul sync e2e", () => {
|
|
|
104
104
|
repo.upsertSession({
|
|
105
105
|
id: "sess-001",
|
|
106
106
|
project_path: "/projects/test",
|
|
107
|
+
parent_session_id: null,
|
|
108
|
+
agent_type: null,
|
|
107
109
|
started_at: "2026-03-28T10:00:00Z",
|
|
108
110
|
total_input_tokens: 100,
|
|
109
111
|
total_output_tokens: 50,
|
|
@@ -249,6 +251,8 @@ describe("zozul sync e2e", () => {
|
|
|
249
251
|
repo.upsertSession({
|
|
250
252
|
id: "sess-002",
|
|
251
253
|
project_path: null,
|
|
254
|
+
parent_session_id: null,
|
|
255
|
+
agent_type: null,
|
|
252
256
|
started_at: "2026-03-28T11:00:00Z",
|
|
253
257
|
total_input_tokens: 10,
|
|
254
258
|
total_output_tokens: 10,
|
|
@@ -296,6 +300,8 @@ describe("zozul sync e2e", () => {
|
|
|
296
300
|
repo.upsertSession({
|
|
297
301
|
id: "sess-A",
|
|
298
302
|
project_path: null,
|
|
303
|
+
parent_session_id: null,
|
|
304
|
+
agent_type: null,
|
|
299
305
|
started_at: "2026-03-28T12:00:00Z",
|
|
300
306
|
total_input_tokens: 10,
|
|
301
307
|
total_output_tokens: 10,
|
|
@@ -330,6 +336,8 @@ describe("zozul sync e2e", () => {
|
|
|
330
336
|
repo.upsertSession({
|
|
331
337
|
id: "sess-B",
|
|
332
338
|
project_path: "/new",
|
|
339
|
+
parent_session_id: null,
|
|
340
|
+
agent_type: null,
|
|
333
341
|
started_at: "2026-03-28T13:00:00Z",
|
|
334
342
|
total_input_tokens: 20,
|
|
335
343
|
total_output_tokens: 20,
|
|
@@ -369,6 +377,8 @@ describe("zozul sync e2e", () => {
|
|
|
369
377
|
repo.upsertSession({
|
|
370
378
|
id: "sess-dry",
|
|
371
379
|
project_path: null,
|
|
380
|
+
parent_session_id: null,
|
|
381
|
+
agent_type: null,
|
|
372
382
|
started_at: "2026-03-28T14:00:00Z",
|
|
373
383
|
total_input_tokens: 5,
|
|
374
384
|
total_output_tokens: 5,
|
|
@@ -411,6 +421,8 @@ describe("zozul sync e2e", () => {
|
|
|
411
421
|
repo.upsertSession({
|
|
412
422
|
id: "sess-err",
|
|
413
423
|
project_path: null,
|
|
424
|
+
parent_session_id: null,
|
|
425
|
+
agent_type: null,
|
|
414
426
|
started_at: "2026-03-28T15:00:00Z",
|
|
415
427
|
total_input_tokens: 10,
|
|
416
428
|
total_output_tokens: 10,
|
package/src/sync/transform.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
export type ApiSession = {
|
|
8
8
|
id: string;
|
|
9
9
|
project_path: string | null;
|
|
10
|
+
parent_session_id: string | null;
|
|
11
|
+
agent_type: string | null;
|
|
10
12
|
started_at: string;
|
|
11
13
|
ended_at: string | null;
|
|
12
14
|
total_input_tokens: number;
|
|
@@ -103,6 +105,8 @@ export function transformSession(row: SessionRow): ApiSession {
|
|
|
103
105
|
return {
|
|
104
106
|
id: row.id,
|
|
105
107
|
project_path: row.project_path,
|
|
108
|
+
parent_session_id: row.parent_session_id,
|
|
109
|
+
agent_type: row.agent_type,
|
|
106
110
|
started_at: row.started_at,
|
|
107
111
|
ended_at: row.ended_at,
|
|
108
112
|
total_input_tokens: row.total_input_tokens,
|