tau-coding-agent 0.1.5 → 0.2.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.
Files changed (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -12,6 +12,7 @@ import {
12
12
  truncateToWidth,
13
13
  visibleWidth,
14
14
  } from "@earendil-works/pi-tui";
15
+ import { createHash } from "node:crypto";
15
16
  import { createReadStream, type Dirent } from "node:fs";
16
17
  import fs from "node:fs/promises";
17
18
  import os from "node:os";
@@ -22,7 +23,10 @@ import {
22
23
  formatCount,
23
24
  formatUsedPercent,
24
25
  isLiveUsageUnavailableError,
26
+ parseISODate,
25
27
  readNumber,
28
+ readObject,
29
+ readString,
26
30
  } from "./shared.js";
27
31
  import type {
28
32
  LiveUsageAvailability,
@@ -93,30 +97,23 @@ type LiveUsageState =
93
97
  | { status: "error"; error: string }
94
98
  | { status: "unavailable"; reason: string };
95
99
 
96
- interface ParsedSessionBase {
97
- startedAt: Date;
98
- dayKeyLocal: string;
99
- cwd: CwdKey | null;
100
- dow: DowKey;
101
- tod: TodKey;
102
- modelsUsed: Set<ModelKey>;
100
+ interface HistoricalActivity {
101
+ identity?: string;
102
+ date: Date;
103
+ model: ModelKey;
104
+ provider?: HistoryProviderId;
103
105
  messages: number;
104
106
  tokens: number;
105
107
  totalCost: number;
106
- costByModel: Map<ModelKey, number>;
107
- messagesByModel: Map<ModelKey, number>;
108
- tokensByModel: Map<ModelKey, number>;
109
- }
110
-
111
- interface ParsedAllSession extends ParsedSessionBase {}
112
-
113
- interface ParsedProviderSession extends ParsedSessionBase {
114
- provider: HistoryProviderId;
115
108
  }
116
109
 
117
110
  interface ParsedHistoricalFile {
118
- all: ParsedAllSession | null;
119
- providers: ParsedProviderSession[];
111
+ filePath: string;
112
+ parentPath?: string;
113
+ startedAt?: Date;
114
+ cwd: CwdKey | null;
115
+ models: Map<ModelKey, HistoryProviderId | undefined>;
116
+ activities: HistoricalActivity[];
120
117
  }
121
118
 
122
119
  interface DayAgg {
@@ -370,74 +367,6 @@ function formatProviderLabel(providerId: string): string {
370
367
  return getSupportedProvider(providerId)?.label ?? providerId;
371
368
  }
372
369
 
373
- function extractProviderModelAndUsage(input: unknown): {
374
- provider?: unknown;
375
- model?: unknown;
376
- modelId?: unknown;
377
- usage?: unknown;
378
- role?: unknown;
379
- } {
380
- const obj = input as { message?: Record<string, unknown> } | null | undefined;
381
- const message = obj?.message;
382
- return {
383
- provider: (obj as Record<string, unknown> | undefined)?.provider ?? message?.provider,
384
- model: (obj as Record<string, unknown> | undefined)?.model ?? message?.model,
385
- modelId: (obj as Record<string, unknown> | undefined)?.modelId ?? message?.modelId,
386
- usage: (obj as Record<string, unknown> | undefined)?.usage ?? message?.usage,
387
- role: message?.role,
388
- };
389
- }
390
-
391
- function extractCostTotal(usage: unknown): number {
392
- const input = usage as { cost?: unknown } | null | undefined;
393
- if (!input) return 0;
394
-
395
- const cost = input.cost as Record<string, unknown> | number | string | null | undefined;
396
- const directCost = readNumber(cost);
397
- if (directCost !== undefined) return directCost;
398
- if (!cost || typeof cost !== "object") return 0;
399
-
400
- return readNumber((cost as { total?: unknown }).total) ?? 0;
401
- }
402
-
403
- function extractTokensTotal(usage: unknown): number {
404
- const input = usage as Record<string, unknown> | null | undefined;
405
- if (!input) return 0;
406
-
407
- let total =
408
- readNumber(input.totalTokens) ??
409
- readNumber(input.total_tokens) ??
410
- readNumber(input.tokens) ??
411
- readNumber(input.tokenCount) ??
412
- readNumber(input.token_count) ??
413
- 0;
414
- if (total > 0) return total;
415
-
416
- const tokens = input.tokens as Record<string, unknown> | null | undefined;
417
- total =
418
- readNumber(tokens?.total) ??
419
- readNumber(tokens?.totalTokens) ??
420
- readNumber(tokens?.total_tokens) ??
421
- 0;
422
- if (total > 0) return total;
423
-
424
- const prompt =
425
- readNumber(input.promptTokens) ??
426
- readNumber(input.prompt_tokens) ??
427
- readNumber(input.inputTokens) ??
428
- readNumber(input.input_tokens) ??
429
- 0;
430
- const completion =
431
- readNumber(input.completionTokens) ??
432
- readNumber(input.completion_tokens) ??
433
- readNumber(input.outputTokens) ??
434
- readNumber(input.output_tokens) ??
435
- 0;
436
-
437
- const sum = prompt + completion;
438
- return sum > 0 ? sum : 0;
439
- }
440
-
441
370
  function modelKeyFromParts(provider?: unknown, model?: unknown): ModelKey | undefined {
442
371
  const providerText = typeof provider === "string" ? provider.trim() : "";
443
372
  const modelText = typeof model === "string" ? model.trim() : "";
@@ -447,23 +376,8 @@ function modelKeyFromParts(provider?: unknown, model?: unknown): ModelKey | unde
447
376
  return `${providerText}/${modelText}`;
448
377
  }
449
378
 
450
- function resolveModelKey(
451
- provider?: unknown,
452
- model?: unknown,
453
- modelId?: unknown,
454
- ): ModelKey | undefined {
455
- const modelIdText = typeof modelId === "string" ? modelId.trim() : "";
456
- if (modelIdText) return modelKeyFromParts(provider, modelIdText);
457
-
458
- const modelText = typeof model === "string" ? model.trim() : "";
459
- if (modelText) return modelKeyFromParts(provider, modelText);
460
-
461
- return modelKeyFromParts(provider);
462
- }
463
-
464
379
  async function walkSessionFiles(
465
380
  root: string,
466
- startCutoffLocal: Date,
467
381
  signal?: AbortSignal,
468
382
  onFound?: (found: number) => void,
469
383
  ): Promise<string[]> {
@@ -489,25 +403,8 @@ async function walkSessionFiles(
489
403
  }
490
404
  if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
491
405
 
492
- const startedAt = parseSessionStartFromFilename(entry.name);
493
- if (startedAt) {
494
- if (localMidnight(startedAt) >= startCutoffLocal) {
495
- output.push(filePath);
496
- if (onFound && output.length % 10 === 0) onFound(output.length);
497
- }
498
- continue;
499
- }
500
-
501
- try {
502
- const stats = await fs.stat(filePath);
503
- const approx = new Date(stats.mtimeMs);
504
- if (localMidnight(approx) >= startCutoffLocal) {
505
- output.push(filePath);
506
- if (onFound && output.length % 10 === 0) onFound(output.length);
507
- }
508
- } catch {
509
- // Ignore unreadable files.
510
- }
406
+ output.push(filePath);
407
+ if (onFound && output.length % 10 === 0) onFound(output.length);
511
408
  }
512
409
  }
513
410
 
@@ -517,62 +414,17 @@ async function walkSessionFiles(
517
414
 
518
415
  async function parseHistoricalFile(
519
416
  filePath: string,
417
+ startCutoff: Date,
520
418
  signal?: AbortSignal,
521
419
  ): Promise<ParsedHistoricalFile> {
522
- const fileName = path.basename(filePath);
523
- let startedAt = parseSessionStartFromFilename(fileName);
524
- let cwd: CwdKey | null = null;
525
- let currentModelAll: ModelKey | undefined;
526
- const currentModelByProvider = new Map<HistoryProviderId, ModelKey>();
527
-
528
- let allSession: ParsedAllSession | null = null;
529
- const providerSessions = new Map<HistoryProviderId, ParsedProviderSession>();
530
-
531
- const createParsedSessionBase = (baseDate: Date): ParsedSessionBase => ({
532
- startedAt: baseDate,
533
- dayKeyLocal: toLocalDayKey(baseDate),
534
- cwd,
535
- dow: DOW_NAMES[mondayIndex(baseDate)] ?? "Mon",
536
- tod: todBucketForHour(baseDate.getHours()),
537
- modelsUsed: new Set<ModelKey>(),
538
- messages: 0,
539
- tokens: 0,
540
- totalCost: 0,
541
- costByModel: new Map<ModelKey, number>(),
542
- messagesByModel: new Map<ModelKey, number>(),
543
- tokensByModel: new Map<ModelKey, number>(),
544
- });
545
-
546
- const getAllSession = (): ParsedAllSession => {
547
- const baseDate = startedAt ?? new Date();
548
- if (allSession) return allSession;
549
- allSession = createParsedSessionBase(baseDate);
550
- return allSession;
551
- };
552
-
553
- const getProviderSession = (provider: HistoryProviderId): ParsedProviderSession => {
554
- const existing = providerSessions.get(provider);
555
- if (existing) return existing;
556
-
557
- const baseDate = startedAt ?? new Date();
558
- const created: ParsedProviderSession = {
559
- provider,
560
- ...createParsedSessionBase(baseDate),
561
- };
562
- providerSessions.set(provider, created);
563
- return created;
564
- };
565
-
566
- const updateSessionBaseFields = (session: ParsedAllSession | ParsedProviderSession): void => {
567
- const baseDate = startedAt ?? session.startedAt;
568
- session.startedAt = baseDate;
569
- session.dayKeyLocal = toLocalDayKey(baseDate);
570
- session.cwd = cwd;
571
- session.dow = DOW_NAMES[mondayIndex(baseDate)] ?? "Mon";
572
- session.tod = todBucketForHour(baseDate.getHours());
420
+ const parsed: ParsedHistoricalFile = {
421
+ filePath: await fs.realpath(filePath),
422
+ startedAt: parseSessionStartFromFilename(path.basename(filePath)),
423
+ cwd: null,
424
+ models: new Map(),
425
+ activities: [],
573
426
  };
574
-
575
- const stream = createReadStream(filePath, { encoding: "utf8" });
427
+ const stream = createReadStream(filePath, { encoding: "utf8", signal });
576
428
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
577
429
 
578
430
  try {
@@ -580,111 +432,104 @@ async function parseHistoricalFile(
580
432
  throwIfAborted(signal);
581
433
  if (!line) continue;
582
434
 
583
- let obj: Record<string, unknown>;
435
+ let entry: Record<string, unknown> | undefined;
584
436
  try {
585
- obj = JSON.parse(line) as Record<string, unknown>;
437
+ entry = readObject(JSON.parse(line));
586
438
  } catch {
587
439
  continue;
588
440
  }
589
-
590
- if (obj.type === "session") {
591
- if (!startedAt && typeof obj.timestamp === "string") {
592
- const parsed = new Date(obj.timestamp);
593
- if (Number.isFinite(parsed.getTime())) startedAt = parsed;
441
+ if (!entry) continue;
442
+
443
+ if (entry.type === "session") {
444
+ parsed.startedAt = parseISODate(readString(entry.timestamp)) ?? parsed.startedAt;
445
+ if (typeof entry.cwd === "string" && entry.cwd) parsed.cwd = entry.cwd;
446
+ if (typeof entry.parentSession === "string" && entry.parentSession) {
447
+ const parentPath = path.resolve(path.dirname(filePath), entry.parentSession);
448
+ parsed.parentPath = await fs.realpath(parentPath).catch(() => parentPath);
594
449
  }
595
- if (typeof obj.cwd === "string" && obj.cwd.trim()) cwd = obj.cwd.trim();
596
450
  continue;
597
451
  }
598
452
 
599
- if (obj.type === "model_change") {
600
- const provider = normalizeHistoryProvider(obj.provider);
601
- const fullModel = resolveModelKey(obj.provider, obj.model, obj.modelId);
602
- if (fullModel) currentModelAll = fullModel;
603
- if (provider) {
604
- const providerSession = getProviderSession(provider);
605
- if (fullModel) {
606
- currentModelByProvider.set(provider, fullModel);
607
- providerSession.modelsUsed.add(fullModel);
608
- }
609
- }
453
+ if (entry.type === "model_change") {
454
+ const model = modelKeyFromParts(entry.provider, entry.modelId);
455
+ if (model) parsed.models.set(model, normalizeHistoryProvider(entry.provider));
610
456
  continue;
611
457
  }
612
458
 
613
- if (obj.type !== "message") continue;
614
-
615
- const {
616
- provider: rawProvider,
617
- model,
618
- modelId,
619
- usage,
620
- role,
621
- } = extractProviderModelAndUsage(obj);
622
-
623
- const aggregate = getAllSession();
624
- const aggregateModelKey =
625
- resolveModelKey(rawProvider, model, modelId) ?? currentModelAll ?? "unknown";
626
- aggregate.modelsUsed.add(aggregateModelKey);
627
- aggregate.messages += 1;
628
- addToMap(aggregate.messagesByModel, aggregateModelKey, 1);
629
-
630
- const tokens = extractTokensTotal(usage);
631
- if (tokens > 0) {
632
- aggregate.tokens += tokens;
633
- addToMap(aggregate.tokensByModel, aggregateModelKey, tokens);
634
- }
635
-
636
- const cost = extractCostTotal(usage);
637
- if (cost > 0) {
638
- aggregate.totalCost += cost;
639
- addToMap(aggregate.costByModel, aggregateModelKey, cost);
640
- }
641
-
642
- if (role !== "assistant") continue;
643
-
644
- const explicitProvider = normalizeHistoryProvider(rawProvider);
645
- const fallbackProvider = currentModelAll?.includes("/")
646
- ? currentModelAll.slice(0, currentModelAll.indexOf("/"))
647
- : undefined;
648
- const provider = explicitProvider ?? fallbackProvider;
649
- if (!provider) continue;
650
-
651
- const providerSession = getProviderSession(provider);
652
- const providerModel =
653
- resolveModelKey(rawProvider, model, modelId) ??
654
- currentModelByProvider.get(provider) ??
655
- "unknown";
656
-
657
- providerSession.modelsUsed.add(providerModel);
658
- providerSession.messages += 1;
659
- addToMap(providerSession.messagesByModel, providerModel, 1);
660
-
661
- if (tokens > 0) {
662
- providerSession.tokens += tokens;
663
- addToMap(providerSession.tokensByModel, providerModel, tokens);
664
- }
665
-
666
- if (cost > 0) {
667
- providerSession.totalCost += cost;
668
- addToMap(providerSession.costByModel, providerModel, cost);
459
+ const activity = parseHistoricalActivity(entry);
460
+ if (!activity) continue;
461
+ if (activity.provider) parsed.models.set(activity.model, activity.provider);
462
+ if (activity.date < startCutoff) continue;
463
+
464
+ // Forks preserve identity/content but can rechain parents and compaction boundaries.
465
+ if (readString(entry.id)) {
466
+ activity.identity = createHash("sha256")
467
+ .update(
468
+ JSON.stringify([
469
+ entry.type,
470
+ entry.id,
471
+ entry.timestamp,
472
+ entry.type === "message"
473
+ ? entry.message
474
+ : entry.type === "usage"
475
+ ? [entry.kind, entry.provider, entry.model, entry.usage]
476
+ : [entry.summary, entry.usage],
477
+ ]),
478
+ )
479
+ .digest("hex");
669
480
  }
481
+ parsed.activities.push(activity);
670
482
  }
671
483
  } finally {
672
484
  rl.close();
673
485
  stream.destroy();
674
486
  }
675
487
 
676
- if (!allSession && startedAt) {
677
- allSession = createParsedSessionBase(startedAt);
678
- }
488
+ return parsed;
489
+ }
679
490
 
680
- if (allSession) updateSessionBaseFields(allSession);
681
- for (const providerSession of providerSessions.values()) {
682
- updateSessionBaseFields(providerSession);
683
- }
491
+ function parseHistoricalActivity(entry: Record<string, unknown>): HistoricalActivity | undefined {
492
+ const message = entry.type === "message" ? readObject(entry.message) : undefined;
493
+ const isSummary = entry.type === "compaction" || entry.type === "branch_summary";
494
+ const isUsage = entry.type === "usage";
495
+ if (!message && !isSummary && !isUsage) return undefined;
496
+ if (message && typeof message.role !== "string") return undefined;
497
+
498
+ const messageTimestamp = readNumber(message?.timestamp);
499
+ const date =
500
+ messageTimestamp !== undefined
501
+ ? new Date(messageTimestamp)
502
+ : parseISODate(readString(entry.timestamp));
503
+ if (!date || !Number.isFinite(date.getTime())) return undefined;
504
+
505
+ const isAssistant = message?.role === "assistant";
506
+ const isTool = message?.role === "toolResult";
507
+ const usage = readObject(
508
+ isSummary || isUsage ? entry.usage : isAssistant || isTool ? message?.usage : undefined,
509
+ );
510
+ const cost = readObject(usage?.cost);
511
+ const components = ["input", "output", "cacheRead", "cacheWrite"] as const;
512
+ const tokens =
513
+ readNumber(usage?.totalTokens) ||
514
+ components.reduce((sum, key) => sum + Math.max(0, readNumber(usage?.[key]) ?? 0), 0);
515
+ const provider = normalizeHistoryProvider(
516
+ isUsage ? entry.provider : isAssistant ? message?.provider : undefined,
517
+ );
518
+ const modelId = isUsage ? entry.model : (message?.responseModel ?? message?.model);
519
+ const model =
520
+ isUsage || isAssistant
521
+ ? (modelKeyFromParts(provider, modelId) ?? "unknown")
522
+ : isTool || isSummary
523
+ ? "Tools/summaries"
524
+ : "Other messages";
684
525
 
685
526
  return {
686
- all: allSession,
687
- providers: [...providerSessions.values()],
527
+ date,
528
+ provider,
529
+ model,
530
+ messages: message ? 1 : 0,
531
+ tokens: Math.max(0, tokens),
532
+ totalCost: Math.max(0, readNumber(cost?.total) ?? 0),
688
533
  };
689
534
  }
690
535
 
@@ -750,7 +595,7 @@ function buildRangeAgg(days: number, now: Date): RangeAgg {
750
595
  const emptyRangeCache = new Map<number, { dayKey: string; range: RangeAgg }>();
751
596
 
752
597
  function addToMap<K>(map: Map<K, number>, key: K, value: number): void {
753
- map.set(key, (map.get(key) ?? 0) + value);
598
+ if (value !== 0) map.set(key, (map.get(key) ?? 0) + value);
754
599
  }
755
600
 
756
601
  function getEmptyRange(days: number, now = new Date()): RangeAgg {
@@ -765,66 +610,72 @@ function getEmptyRange(days: number, now = new Date()): RangeAgg {
765
610
 
766
611
  function addSessionToRange(
767
612
  range: RangeAgg,
768
- session: ParsedAllSession | ParsedProviderSession,
613
+ session: ParsedHistoricalFile,
614
+ models: Iterable<ModelKey>,
769
615
  ): void {
770
- const day = range.dayByKey.get(session.dayKeyLocal);
616
+ if (!session.startedAt) return;
617
+ const day = range.dayByKey.get(toLocalDayKey(session.startedAt));
771
618
  if (!day) return;
772
619
 
773
620
  range.sessions += 1;
774
- range.totalMessages += session.messages;
775
- range.totalTokens += session.tokens;
776
- range.totalCost += session.totalCost;
777
-
778
621
  day.sessions += 1;
779
- day.messages += session.messages;
780
- day.tokens += session.tokens;
781
- day.totalCost += session.totalCost;
782
-
783
- for (const model of session.modelsUsed) {
622
+ for (const model of models) {
784
623
  addToMap(day.sessionsByModel, model, 1);
785
624
  addToMap(range.modelSessions, model, 1);
786
625
  }
787
-
788
- for (const [model, count] of session.messagesByModel.entries()) {
789
- addToMap(day.messagesByModel, model, count);
790
- addToMap(range.modelMessages, model, count);
791
- }
792
-
793
- for (const [model, count] of session.tokensByModel.entries()) {
794
- addToMap(day.tokensByModel, model, count);
795
- addToMap(range.modelTokens, model, count);
796
- }
797
-
798
- for (const [model, cost] of session.costByModel.entries()) {
799
- addToMap(day.costByModel, model, cost);
800
- addToMap(range.modelCost, model, cost);
801
- }
802
-
803
626
  if (session.cwd) {
804
627
  addToMap(day.sessionsByCwd, session.cwd, 1);
805
628
  addToMap(range.cwdSessions, session.cwd, 1);
806
- addToMap(day.messagesByCwd, session.cwd, session.messages);
807
- addToMap(range.cwdMessages, session.cwd, session.messages);
808
- addToMap(day.tokensByCwd, session.cwd, session.tokens);
809
- addToMap(range.cwdTokens, session.cwd, session.tokens);
810
- addToMap(day.costByCwd, session.cwd, session.totalCost);
811
- addToMap(range.cwdCost, session.cwd, session.totalCost);
812
629
  }
630
+ const dow = DOW_NAMES[mondayIndex(session.startedAt)] ?? "Mon";
631
+ const tod = todBucketForHour(session.startedAt.getHours());
632
+ addToMap(range.dowSessions, dow, 1);
633
+ addToMap(day.sessionsByTod, tod, 1);
634
+ addToMap(range.todSessions, tod, 1);
635
+ }
813
636
 
814
- addToMap(range.dowSessions, session.dow, 1);
815
- addToMap(range.dowMessages, session.dow, session.messages);
816
- addToMap(range.dowTokens, session.dow, session.tokens);
817
- addToMap(range.dowCost, session.dow, session.totalCost);
637
+ function addActivityToRange(
638
+ range: RangeAgg,
639
+ activity: HistoricalActivity,
640
+ cwd: CwdKey | null,
641
+ ): void {
642
+ const day = range.dayByKey.get(toLocalDayKey(activity.date));
643
+ if (!day) return;
818
644
 
819
- addToMap(day.sessionsByTod, session.tod, 1);
820
- addToMap(day.messagesByTod, session.tod, session.messages);
821
- addToMap(day.tokensByTod, session.tod, session.tokens);
822
- addToMap(day.costByTod, session.tod, session.totalCost);
645
+ range.totalMessages += activity.messages;
646
+ range.totalTokens += activity.tokens;
647
+ range.totalCost += activity.totalCost;
648
+ day.messages += activity.messages;
649
+ day.tokens += activity.tokens;
650
+ day.totalCost += activity.totalCost;
651
+
652
+ addToMap(day.messagesByModel, activity.model, activity.messages);
653
+ addToMap(range.modelMessages, activity.model, activity.messages);
654
+ addToMap(day.tokensByModel, activity.model, activity.tokens);
655
+ addToMap(range.modelTokens, activity.model, activity.tokens);
656
+ addToMap(day.costByModel, activity.model, activity.totalCost);
657
+ addToMap(range.modelCost, activity.model, activity.totalCost);
658
+
659
+ if (cwd) {
660
+ addToMap(day.messagesByCwd, cwd, activity.messages);
661
+ addToMap(range.cwdMessages, cwd, activity.messages);
662
+ addToMap(day.tokensByCwd, cwd, activity.tokens);
663
+ addToMap(range.cwdTokens, cwd, activity.tokens);
664
+ addToMap(day.costByCwd, cwd, activity.totalCost);
665
+ addToMap(range.cwdCost, cwd, activity.totalCost);
666
+ }
823
667
 
824
- addToMap(range.todSessions, session.tod, 1);
825
- addToMap(range.todMessages, session.tod, session.messages);
826
- addToMap(range.todTokens, session.tod, session.tokens);
827
- addToMap(range.todCost, session.tod, session.totalCost);
668
+ const dow = DOW_NAMES[mondayIndex(activity.date)] ?? "Mon";
669
+ const tod = todBucketForHour(activity.date.getHours());
670
+ addToMap(range.dowMessages, dow, activity.messages);
671
+ addToMap(range.dowTokens, dow, activity.tokens);
672
+ addToMap(range.dowCost, dow, activity.totalCost);
673
+ addToMap(day.messagesByTod, tod, activity.messages);
674
+ addToMap(day.tokensByTod, tod, activity.tokens);
675
+ addToMap(day.costByTod, tod, activity.totalCost);
676
+ addToMap(range.todMessages, tod, activity.messages);
677
+ addToMap(range.todTokens, tod, activity.tokens);
678
+ addToMap(range.todCost, tod, activity.totalCost);
828
679
  }
829
680
 
830
681
  function sortMapByValueDesc<K extends string>(
@@ -1403,26 +1254,22 @@ function renderTodTable(range: RangeAgg, metric: GraphMetric): string[] {
1403
1254
  }
1404
1255
 
1405
1256
  function rangeSummary(range: RangeAgg, days: number, mode: MeasurementMode): string {
1406
- const avg = range.sessions > 0 ? range.totalCost / range.sessions : 0;
1407
- const costPart =
1408
- range.totalCost > 0
1409
- ? `${formatUsd(range.totalCost)} · avg ${formatUsd(avg)}/session`
1410
- : "$0.0000";
1257
+ const costPart = formatUsd(range.totalCost);
1411
1258
 
1412
1259
  if (mode === "tokens") {
1413
- return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalTokens)} tokens · ${costPart}`;
1260
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions started · ${formatCount(range.totalTokens)} tokens · ${costPart}`;
1414
1261
  }
1415
1262
  if (mode === "messages") {
1416
- return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${formatCount(range.totalMessages)} messages · ${costPart}`;
1263
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions started · ${formatCount(range.totalMessages)} messages · ${costPart}`;
1417
1264
  }
1418
- return `Last ${days} days: ${formatCount(range.sessions)} sessions · ${costPart}`;
1265
+ return `Last ${days} days: ${formatCount(range.sessions)} sessions started · ${costPart}`;
1419
1266
  }
1420
1267
 
1421
1268
  function historySemanticsText(tab: BreakdownTab): string {
1422
1269
  if (tab.mode === "all") {
1423
- return "history: sessions = session files; messages = all Pi message rows; tokens = assistant usage tokens";
1270
+ return "history: sessions = files started; messages/usage = unique activity by date, including tools and summaries";
1424
1271
  }
1425
- return "history: sessions = session files where this provider appeared; messages/tokens = provider-attributed assistant usage";
1272
+ return "history: sessions = files started using this provider; messages = assistant entries; usage = unique attributed activity by date";
1426
1273
  }
1427
1274
 
1428
1275
  async function getLiveUsageAvailability(
@@ -1433,7 +1280,7 @@ async function getLiveUsageAvailability(
1433
1280
  return provider.getLiveUsageAvailability(ctx);
1434
1281
  }
1435
1282
 
1436
- return ctx.modelRegistry.authStorage.hasAuth(provider.id)
1283
+ return ctx.modelRegistry.getProviderAuthStatus(provider.id).configured
1437
1284
  ? { available: true }
1438
1285
  : { available: false, reason: "no auth configured" };
1439
1286
  }
@@ -1565,7 +1412,6 @@ async function computeBreakdown(
1565
1412
  for (const days of RANGE_DAYS) allRanges.set(days, buildRangeAgg(days, now));
1566
1413
 
1567
1414
  const providerRangesById = new Map<HistoryProviderId, Map<number, RangeAgg>>();
1568
- const historyProviderIds = new Set<HistoryProviderId>();
1569
1415
 
1570
1416
  const range90 = allRanges.get(90)!;
1571
1417
  const start90 = range90.days[0]?.date ?? addDaysLocal(localMidnight(now), -89);
@@ -1577,7 +1423,7 @@ async function computeBreakdown(
1577
1423
  totalFiles: 0,
1578
1424
  currentFile: undefined,
1579
1425
  });
1580
- const candidates = await walkSessionFiles(SESSION_ROOT, start90, signal, (found) => {
1426
+ const candidates = await walkSessionFiles(SESSION_ROOT, signal, (found) => {
1581
1427
  onProgress?.({ phase: "scan", foundFiles: found });
1582
1428
  });
1583
1429
 
@@ -1589,39 +1435,7 @@ async function computeBreakdown(
1589
1435
  currentFile: candidates[0] ? path.basename(candidates[0]) : undefined,
1590
1436
  });
1591
1437
 
1592
- const addParsedFile = (parsed: ParsedHistoricalFile): void => {
1593
- if (parsed.all) {
1594
- const sessionDay = localMidnight(parsed.all.startedAt);
1595
- for (const days of RANGE_DAYS) {
1596
- const range = allRanges.get(days)!;
1597
- const start = range.days[0]?.date;
1598
- const end = range.days[range.days.length - 1]?.date;
1599
- if (!start || !end) continue;
1600
- if (sessionDay < start || sessionDay > end) continue;
1601
- addSessionToRange(range, parsed.all);
1602
- }
1603
- }
1604
-
1605
- for (const providerSession of parsed.providers) {
1606
- historyProviderIds.add(providerSession.provider);
1607
- let providerRanges = providerRangesById.get(providerSession.provider);
1608
- if (!providerRanges) {
1609
- providerRanges = new Map<number, RangeAgg>();
1610
- for (const days of RANGE_DAYS) providerRanges.set(days, buildRangeAgg(days, now));
1611
- providerRangesById.set(providerSession.provider, providerRanges);
1612
- }
1613
-
1614
- const sessionDay = localMidnight(providerSession.startedAt);
1615
- for (const days of RANGE_DAYS) {
1616
- const range = providerRanges.get(days)!;
1617
- const start = range.days[0]?.date;
1618
- const end = range.days[range.days.length - 1]?.date;
1619
- if (!start || !end) continue;
1620
- if (sessionDay < start || sessionDay > end) continue;
1621
- addSessionToRange(range, providerSession);
1622
- }
1623
- }
1624
- };
1438
+ const filesByPath = new Map<string, ParsedHistoricalFile>();
1625
1439
 
1626
1440
  let parsedFiles = 0;
1627
1441
  let nextFileIndex = 0;
@@ -1637,8 +1451,8 @@ async function computeBreakdown(
1637
1451
  const filePath = candidates[currentIndex];
1638
1452
  if (!filePath) return;
1639
1453
 
1640
- const parsed = await parseHistoricalFile(filePath, signal);
1641
- addParsedFile(parsed);
1454
+ const parsed = await parseHistoricalFile(filePath, start90, signal);
1455
+ filesByPath.set(parsed.filePath, parsed);
1642
1456
 
1643
1457
  parsedFiles += 1;
1644
1458
  onProgress?.({
@@ -1653,6 +1467,78 @@ async function computeBreakdown(
1653
1467
 
1654
1468
  onProgress?.({ phase: "finalize", currentFile: undefined });
1655
1469
 
1470
+ const getProviderRanges = (provider: HistoryProviderId): Map<number, RangeAgg> => {
1471
+ let ranges = providerRangesById.get(provider);
1472
+ if (!ranges) {
1473
+ ranges = new Map(RANGE_DAYS.map((days) => [days, buildRangeAgg(days, now)]));
1474
+ providerRangesById.set(provider, ranges);
1475
+ }
1476
+ return ranges;
1477
+ };
1478
+
1479
+ const lineageByPath = new Map<string, string>();
1480
+ const seenByLineage = new Map<string, Set<string>>();
1481
+ const addParsedFile = (file: ParsedHistoricalFile): string => {
1482
+ throwIfAborted(signal);
1483
+ const existing = lineageByPath.get(file.filePath);
1484
+ if (existing) return existing;
1485
+ // Mark before following parents so malformed ancestry cycles cannot recurse forever.
1486
+ lineageByPath.set(file.filePath, file.filePath);
1487
+ const parent = file.parentPath ? filesByPath.get(file.parentPath) : undefined;
1488
+ const lineage = parent ? addParsedFile(parent) : (file.parentPath ?? file.filePath);
1489
+ lineageByPath.set(file.filePath, lineage);
1490
+
1491
+ if (file.startedAt && range90.dayByKey.has(toLocalDayKey(file.startedAt))) {
1492
+ for (const range of allRanges.values()) {
1493
+ addSessionToRange(range, file, file.models.keys());
1494
+ }
1495
+ const providerModels = new Map<HistoryProviderId, Set<ModelKey>>();
1496
+ for (const [model, provider] of file.models) {
1497
+ if (!provider) continue;
1498
+ let models = providerModels.get(provider);
1499
+ if (!models) {
1500
+ models = new Set();
1501
+ providerModels.set(provider, models);
1502
+ }
1503
+ models.add(model);
1504
+ }
1505
+ for (const [provider, models] of providerModels) {
1506
+ for (const range of getProviderRanges(provider).values()) {
1507
+ addSessionToRange(range, file, models);
1508
+ }
1509
+ }
1510
+ }
1511
+
1512
+ let seen = seenByLineage.get(lineage);
1513
+ if (!seen) {
1514
+ seen = new Set();
1515
+ seenByLineage.set(lineage, seen);
1516
+ }
1517
+ for (const activity of file.activities) {
1518
+ throwIfAborted(signal);
1519
+ if (!range90.dayByKey.has(toLocalDayKey(activity.date))) continue;
1520
+ if (activity.identity) {
1521
+ if (seen.has(activity.identity)) continue;
1522
+ seen.add(activity.identity);
1523
+ }
1524
+ for (const range of allRanges.values()) addActivityToRange(range, activity, file.cwd);
1525
+ if (activity.provider) {
1526
+ for (const range of getProviderRanges(activity.provider).values()) {
1527
+ addActivityToRange(range, activity, file.cwd);
1528
+ }
1529
+ }
1530
+ }
1531
+ return lineage;
1532
+ };
1533
+
1534
+ // Parents own inherited activity; if one is gone, prefer the oldest surviving copy.
1535
+ const files = [...filesByPath.values()].sort(
1536
+ (left, right) =>
1537
+ (left.startedAt?.getTime() ?? 0) - (right.startedAt?.getTime() ?? 0) ||
1538
+ left.filePath.localeCompare(right.filePath),
1539
+ );
1540
+ for (const file of files) addParsedFile(file);
1541
+
1656
1542
  const allPaletteRange = allRanges.get(30)!;
1657
1543
  const allPalette: ProviderPalette = {
1658
1544
  modelPalette: chooseModelPalette(allPaletteRange),
@@ -1676,7 +1562,7 @@ async function computeBreakdown(
1676
1562
  const liveAvailableProviders = getLiveAvailableProviders(availabilityByProvider);
1677
1563
 
1678
1564
  return {
1679
- tabs: buildTabs(historyProviderIds, liveAvailableProviders),
1565
+ tabs: buildTabs(new Set(providerRangesById.keys()), liveAvailableProviders),
1680
1566
  liveUsage: buildInitialLiveUsage(SUPPORTED_PROVIDERS, availabilityByProvider),
1681
1567
  allRanges,
1682
1568
  providerRangesById,
@@ -1861,6 +1747,7 @@ class UsageBreakdownComponent implements Component {
1861
1747
  ) => Promise<LiveUsageState>;
1862
1748
  private readonly liveUsageRequests = new Map<SupportedProviderId, Promise<void>>();
1863
1749
  private readonly liveUsageControllers = new Map<SupportedProviderId, AbortController>();
1750
+ private closing = false;
1864
1751
  private tabIndex = 0;
1865
1752
  private rangeIndex = 1;
1866
1753
  private measurement: MeasurementMode = "tokens";
@@ -1884,11 +1771,13 @@ class UsageBreakdownComponent implements Component {
1884
1771
  this.ensureLiveUsageLoaded();
1885
1772
  }
1886
1773
 
1887
- private close(): void {
1774
+ private async close(): Promise<void> {
1775
+ if (this.closing) return;
1776
+ this.closing = true;
1888
1777
  for (const controller of this.liveUsageControllers.values()) {
1889
1778
  controller.abort();
1890
1779
  }
1891
- this.liveUsageControllers.clear();
1780
+ await Promise.allSettled(this.liveUsageRequests.values());
1892
1781
  this.onDone();
1893
1782
  }
1894
1783
 
@@ -1937,12 +1826,13 @@ class UsageBreakdownComponent implements Component {
1937
1826
  }
1938
1827
 
1939
1828
  handleInput(data: string): void {
1829
+ if (this.closing) return;
1940
1830
  if (
1941
1831
  matchesKey(data, Key.escape) ||
1942
1832
  matchesKey(data, Key.ctrl("c")) ||
1943
1833
  data.toLowerCase() === "q"
1944
1834
  ) {
1945
- this.close();
1835
+ void this.close();
1946
1836
  return;
1947
1837
  }
1948
1838
 
@@ -2292,9 +2182,12 @@ export default function usageBreakdownExtension(pi: ExtensionAPI) {
2292
2182
  return;
2293
2183
  }
2294
2184
 
2295
- const allRange = data.allRanges.get(30)!;
2185
+ const allRange = data.allRanges.get(90)!;
2296
2186
  const hasHistory =
2297
- allRange.sessions > 0 || allRange.totalMessages > 0 || allRange.totalTokens > 0;
2187
+ allRange.sessions > 0 ||
2188
+ allRange.totalMessages > 0 ||
2189
+ allRange.totalTokens > 0 ||
2190
+ allRange.totalCost > 0;
2298
2191
  const hasLiveQuotaSources = [...liveUsageAvailability.values()].some(
2299
2192
  (availability) => availability.available,
2300
2193
  );