zozul-cli 0.3.7 → 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 +29 -18
  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 +30 -18
  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,15 +22,17 @@ 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),
@@ -45,6 +47,8 @@ export class SessionRepo {
45
47
  `).run({
46
48
  ...session,
47
49
  ended_at: session.ended_at ?? null,
50
+ parent_session_id: session.parent_session_id ?? null,
51
+ agent_type: session.agent_type ?? null,
48
52
  });
49
53
  }
50
54
 
@@ -179,20 +183,20 @@ export class SessionRepo {
179
183
  listSessions(limit = 50, offset = 0, from?: string, to?: string): SessionRow[] {
180
184
  if (from && to) {
181
185
  return this.db.prepare(`
182
- 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 ?
183
187
  `).all(from, to, limit, offset) as SessionRow[];
184
188
  }
185
189
  return this.db.prepare(`
186
- 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 ?
187
191
  `).all(limit, offset) as SessionRow[];
188
192
  }
189
193
 
190
194
  countSessions(from?: string, to?: string): number {
191
195
  if (from && to) {
192
- 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 };
193
197
  return row.n;
194
198
  }
195
- 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 };
196
200
  return row.n;
197
201
  }
198
202
 
@@ -200,6 +204,12 @@ export class SessionRepo {
200
204
  return this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as SessionRow | undefined;
201
205
  }
202
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
+
203
213
  getSessionTurns(sessionId: string): TurnRow[] {
204
214
  return this.db.prepare(`
205
215
  SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index ASC
@@ -221,7 +231,7 @@ export class SessionRepo {
221
231
  getAggregateStats(from?: string, to?: string) {
222
232
  if (from && to) {
223
233
  return this.db.prepare(`
224
- 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 <= ?),
225
235
  fh AS (SELECT * FROM hook_events WHERE timestamp >= ? AND timestamp <= ?)
226
236
  SELECT
227
237
  (SELECT COUNT(*) FROM fs) as total_sessions,
@@ -237,13 +247,13 @@ export class SessionRepo {
237
247
  }
238
248
  return this.db.prepare(`
239
249
  SELECT
240
- (SELECT COUNT(*) FROM sessions) as total_sessions,
241
- (SELECT SUM(total_input_tokens) FROM sessions) as total_input_tokens,
242
- (SELECT SUM(total_output_tokens) FROM sessions) as total_output_tokens,
243
- (SELECT SUM(total_cache_read_tokens) FROM sessions) as total_cache_read_tokens,
244
- (SELECT SUM(total_cost_usd) FROM sessions) as total_cost_usd,
245
- (SELECT SUM(total_turns) FROM sessions) as total_turns,
246
- (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,
247
257
  (SELECT COUNT(*) FROM hook_events WHERE event_name = 'UserPromptSubmit') as total_user_prompts,
248
258
  (SELECT COUNT(*) FROM hook_events WHERE event_name = 'Stop') as total_interruptions
249
259
  `).get();
@@ -478,6 +488,7 @@ export class SessionRepo {
478
488
 
479
489
  // Only show real user turns (each row = one interaction block)
480
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)");
481
492
 
482
493
  const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
483
494
  params.push(limit, offset);
@@ -602,7 +613,7 @@ export class SessionRepo {
602
613
  * Safe to call repeatedly — always produces correct values from append-only source data.
603
614
  */
604
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 }[] {
605
- const timeFilter = from && to ? "\n WHERE t.timestamp >= ? AND t.timestamp <= ?" : "";
616
+ const timeFilter = from && to ? " AND t.timestamp >= ? AND t.timestamp <= ?" : "";
606
617
  const params: unknown[] = from && to ? [from, to] : [];
607
618
 
608
619
  return this.db.prepare(`
@@ -620,7 +631,8 @@ export class SessionRepo {
620
631
  MAX(t.timestamp) as last_seen
621
632
  FROM turns t
622
633
  LEFT JOIN turn_tag_sets tts ON tts.turn_id = t.id
623
- 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}
624
636
  GROUP BY COALESCE(tts.tag_set, 'Untagged')
625
637
  ORDER BY last_seen DESC
626
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,