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.
Files changed (42) hide show
  1. package/.claude/settings.local.json +4 -1
  2. package/dist/dashboard/index.html +32 -8
  3. package/dist/hooks/server.js +6 -0
  4. package/dist/hooks/server.js.map +1 -1
  5. package/dist/parser/ingest.d.ts +2 -0
  6. package/dist/parser/ingest.d.ts.map +1 -1
  7. package/dist/parser/ingest.js +8 -3
  8. package/dist/parser/ingest.js.map +1 -1
  9. package/dist/parser/jsonl.d.ts +9 -3
  10. package/dist/parser/jsonl.d.ts.map +1 -1
  11. package/dist/parser/jsonl.js +35 -11
  12. package/dist/parser/jsonl.js.map +1 -1
  13. package/dist/parser/types.d.ts +2 -0
  14. package/dist/parser/types.d.ts.map +1 -1
  15. package/dist/parser/watcher.d.ts.map +1 -1
  16. package/dist/parser/watcher.js +37 -10
  17. package/dist/parser/watcher.js.map +1 -1
  18. package/dist/storage/db.d.ts +2 -0
  19. package/dist/storage/db.d.ts.map +1 -1
  20. package/dist/storage/db.js +12 -0
  21. package/dist/storage/db.js.map +1 -1
  22. package/dist/storage/repo.d.ts +1 -0
  23. package/dist/storage/repo.d.ts.map +1 -1
  24. package/dist/storage/repo.js +34 -21
  25. package/dist/storage/repo.js.map +1 -1
  26. package/dist/sync/sync.test.js +12 -0
  27. package/dist/sync/sync.test.js.map +1 -1
  28. package/dist/sync/transform.d.ts +2 -0
  29. package/dist/sync/transform.d.ts.map +1 -1
  30. package/dist/sync/transform.js +2 -0
  31. package/dist/sync/transform.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/dashboard/index.html +32 -8
  34. package/src/hooks/server.ts +7 -0
  35. package/src/parser/ingest.ts +10 -5
  36. package/src/parser/jsonl.ts +43 -7
  37. package/src/parser/types.ts +2 -0
  38. package/src/parser/watcher.ts +34 -9
  39. package/src/storage/db.ts +12 -0
  40. package/src/storage/repo.ts +35 -21
  41. package/src/sync/sync.test.ts +12 -0
  42. package/src/sync/transform.ts +4 -0
@@ -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
- await ingestSessionFile(repo, filePath, projectPath ?? undefined);
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
- * ~/.claude/projects/<encoded>/<uuid>.jsonl
109
- * where <encoded> has "/" replaced with "-".
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
- // Match project dir directly containing the UUID file
113
- const match = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
114
- if (!match) return null;
115
- return match[1].replace(/-/g, "/");
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;
@@ -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, total_input_tokens,
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, @started_at, @ended_at, @total_input_tokens,
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 for tokens so OTEL values aren't clobbered by JSONL re-ingest.
38
- -- Cost and duration are OTEL-only never overwrite from JSONL.
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 ? "\n WHERE t.timestamp >= ? AND t.timestamp <= ?" : "";
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${timeFilter}
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 }[];
@@ -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,
@@ -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,