@leo000001/opencode-quota-sidebar 2.0.1 → 2.0.2
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/README.md +14 -9
- package/dist/format.js +118 -30
- package/dist/index.js +55 -4
- package/dist/persistence.js +15 -1
- package/dist/quota_service.js +129 -11
- package/dist/storage_parse.js +1 -0
- package/dist/title_apply.d.ts +21 -5
- package/dist/title_apply.js +77 -21
- package/dist/title_refresh.d.ts +3 -1
- package/dist/title_refresh.js +25 -2
- package/dist/tools.d.ts +19 -3
- package/dist/tools.js +58 -16
- package/dist/types.d.ts +2 -0
- package/dist/usage.d.ts +2 -0
- package/dist/usage.js +15 -0
- package/dist/usage_service.js +38 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
|
|
|
13
13
|
|
|
14
14
|
```json
|
|
15
15
|
{
|
|
16
|
-
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.
|
|
16
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.1"]
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
@@ -64,13 +64,13 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
64
64
|
- quota lines: quota text like `OpenAI 5h 80% Rst 16:20`; short windows (`5h`, `1d`, `Daily`) show `HH:MM` on same-day resets and `MM-DD HH:MM` when crossing days, while longer windows continue to show `MM-DD`
|
|
65
65
|
- RightCode daily quota shows `$remaining/$dailyTotal` + expiry (e.g. `RC Daily $105/$60 Exp 02-27`, without trailing percent) and also shows balance on the next indented line when available; `Exp` remains date-only
|
|
66
66
|
- Session-scoped usage/quota can include descendant subagent sessions (enabled by default via `sidebar.includeChildren=true`). Traversal is bounded by `childrenMaxDepth` (default 6), `childrenMaxSessions` (default 128), and `childrenConcurrency` (default 5); truncation is logged when `OPENCODE_QUOTA_DEBUG=1`. Day/week/month ranges never merge children — only session scope does.
|
|
67
|
-
- Toast message
|
|
67
|
+
- Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cache coverage is available), and `Quota`
|
|
68
68
|
- `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
|
|
69
69
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
70
70
|
- Custom tools:
|
|
71
71
|
- `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
|
|
72
72
|
- `quota_show` — toggle sidebar title display on/off (state persists across sessions)
|
|
73
|
-
- After startup, titles refresh on the next relevant session/message event or when `quota_show` is toggled
|
|
73
|
+
- After startup, titles are restored immediately when persisted display mode is OFF; when persisted display mode is ON, touched titles refresh on startup and the rest update on the next relevant session/message event or when `quota_show` is toggled
|
|
74
74
|
- Quota connectors:
|
|
75
75
|
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
76
76
|
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
@@ -90,12 +90,17 @@ The plugin stores lightweight global state and date-partitioned session chunks.
|
|
|
90
90
|
- `titleEnabled`
|
|
91
91
|
- `sessionDateMap` (sessionID -> `YYYY-MM-DD`)
|
|
92
92
|
- `quotaCache`
|
|
93
|
-
- Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
|
|
94
|
-
- per-session title state (`baseTitle`, `lastAppliedTitle`)
|
|
95
|
-
- `createdAt`
|
|
96
|
-
- `parentID` (when the session is a subagent child session)
|
|
97
|
-
- cached usage summary used by `quota_summary`
|
|
98
|
-
- incremental aggregation cursor
|
|
93
|
+
- Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
|
|
94
|
+
- per-session title state (`baseTitle`, `lastAppliedTitle`)
|
|
95
|
+
- `createdAt`
|
|
96
|
+
- `parentID` (when the session is a subagent child session)
|
|
97
|
+
- cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
|
|
98
|
+
- incremental aggregation cursor
|
|
99
|
+
|
|
100
|
+
Notes on cache coverage persistence:
|
|
101
|
+
|
|
102
|
+
- Older cached usage written before `cacheBuckets` existed can only be approximated from top-level `cache_read` / `cache_write` totals.
|
|
103
|
+
- In those legacy cases, mixed read-only + read-write cache traffic may be attributed to a single fallback bucket until the session is recomputed from messages.
|
|
99
104
|
|
|
100
105
|
Example tree:
|
|
101
106
|
|
package/dist/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCacheCoverageMetrics } from './usage.js';
|
|
1
|
+
import { getCacheCoverageMetrics, getProviderCacheCoverageMetrics, } from './usage.js';
|
|
2
2
|
import { canonicalProviderID, collapseQuotaSnapshots, displayShortLabel, quotaDisplayLabel, } from './quota_render.js';
|
|
3
3
|
import { stripAnsi } from './title.js';
|
|
4
4
|
/** M6 fix: handle negative, NaN, Infinity gracefully. */
|
|
@@ -161,6 +161,16 @@ function formatPercent(value, decimals = 1) {
|
|
|
161
161
|
const pct = (safe * 100).toFixed(decimals);
|
|
162
162
|
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
163
163
|
}
|
|
164
|
+
function formatQuotaPercent(value, options) {
|
|
165
|
+
const missing = options?.missing ?? '-';
|
|
166
|
+
if (value === undefined)
|
|
167
|
+
return missing;
|
|
168
|
+
if (!Number.isFinite(value) || value < 0)
|
|
169
|
+
return missing;
|
|
170
|
+
if (options?.rounded)
|
|
171
|
+
return `${Math.round(value)}%`;
|
|
172
|
+
return `${value.toFixed(options?.decimals ?? 1)}%`;
|
|
173
|
+
}
|
|
164
174
|
function alignPairs(pairs, indent = ' ') {
|
|
165
175
|
if (pairs.length === 0)
|
|
166
176
|
return [];
|
|
@@ -187,9 +197,10 @@ function compactQuotaInline(quota) {
|
|
|
187
197
|
const first = quota.windows[0];
|
|
188
198
|
const showPercent = first.showPercent !== false;
|
|
189
199
|
const firstLabel = sanitizeLine(first.label || '');
|
|
190
|
-
const pct = first.remainingPercent
|
|
191
|
-
|
|
192
|
-
:
|
|
200
|
+
const pct = formatQuotaPercent(first.remainingPercent, {
|
|
201
|
+
rounded: true,
|
|
202
|
+
missing: '',
|
|
203
|
+
});
|
|
193
204
|
const summary = showPercent
|
|
194
205
|
? [firstLabel, pct].filter(Boolean).join(' ')
|
|
195
206
|
: firstLabel.replace(/^Daily\s+/i, '') || firstLabel;
|
|
@@ -200,8 +211,12 @@ function compactQuotaInline(quota) {
|
|
|
200
211
|
if (quota.balance) {
|
|
201
212
|
return `${label} Balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
|
|
202
213
|
}
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
const singlePercent = formatQuotaPercent(quota.remainingPercent, {
|
|
215
|
+
rounded: true,
|
|
216
|
+
missing: '',
|
|
217
|
+
});
|
|
218
|
+
if (singlePercent) {
|
|
219
|
+
return `${label} ${singlePercent}`;
|
|
205
220
|
}
|
|
206
221
|
return label;
|
|
207
222
|
}
|
|
@@ -351,9 +366,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
351
366
|
: undefined;
|
|
352
367
|
const renderWindow = (win) => {
|
|
353
368
|
const showPercent = win.showPercent !== false;
|
|
354
|
-
const pct = win.remainingPercent
|
|
355
|
-
? '?'
|
|
356
|
-
: `${Math.round(win.remainingPercent)}%`;
|
|
369
|
+
const pct = formatQuotaPercent(win.remainingPercent, { rounded: true });
|
|
357
370
|
const parts = win.label
|
|
358
371
|
? showPercent
|
|
359
372
|
? [sanitizeLine(win.label), pct]
|
|
@@ -387,9 +400,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
387
400
|
return maybeBreak(balanceText, [balanceText]);
|
|
388
401
|
}
|
|
389
402
|
// Fallback: single value from top-level remainingPercent
|
|
390
|
-
const percent = quota.remainingPercent
|
|
391
|
-
? '?'
|
|
392
|
-
: `${Math.round(quota.remainingPercent)}%`;
|
|
403
|
+
const percent = formatQuotaPercent(quota.remainingPercent, { rounded: true });
|
|
393
404
|
const reset = compactReset(quota.resetAt, 'Rst');
|
|
394
405
|
const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
|
|
395
406
|
return maybeBreak(fallbackText, [fallbackText]);
|
|
@@ -499,12 +510,71 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
499
510
|
return '-';
|
|
500
511
|
return formatApiCostValue(usage.apiCost);
|
|
501
512
|
};
|
|
502
|
-
const
|
|
503
|
-
|
|
513
|
+
const cacheCoverageCell = (provider) => {
|
|
514
|
+
const metrics = getProviderCacheCoverageMetrics(provider);
|
|
515
|
+
return metrics.cacheCoverage !== undefined
|
|
516
|
+
? formatPercent(metrics.cacheCoverage, 1)
|
|
517
|
+
: '-';
|
|
518
|
+
};
|
|
519
|
+
const cacheReadCoverageCell = (provider) => {
|
|
520
|
+
const metrics = getProviderCacheCoverageMetrics(provider);
|
|
521
|
+
return metrics.cacheReadCoverage !== undefined
|
|
522
|
+
? formatPercent(metrics.cacheReadCoverage, 1)
|
|
523
|
+
: '-';
|
|
524
|
+
};
|
|
525
|
+
const providerEntries = Object.values(usage.providers).sort((a, b) => b.total - a.total);
|
|
526
|
+
const highlightLines = () => {
|
|
527
|
+
const lines = [];
|
|
528
|
+
const providerLabel = (providerID) => quotaDisplayLabel({
|
|
529
|
+
providerID,
|
|
530
|
+
label: providerID,
|
|
531
|
+
status: 'ok',
|
|
532
|
+
checkedAt: 0,
|
|
533
|
+
});
|
|
534
|
+
const topApiCost = providerEntries
|
|
535
|
+
.filter((provider) => provider.apiCost > 0)
|
|
536
|
+
.sort((a, b) => b.apiCost - a.apiCost)[0];
|
|
537
|
+
if (topApiCost) {
|
|
538
|
+
lines.push(`- Top API cost: ${quotaDisplayLabel({
|
|
539
|
+
providerID: topApiCost.providerID,
|
|
540
|
+
label: topApiCost.providerID,
|
|
541
|
+
status: 'ok',
|
|
542
|
+
checkedAt: 0,
|
|
543
|
+
})} (${formatUsd(topApiCost.apiCost)})`);
|
|
544
|
+
}
|
|
545
|
+
const bestCacheCoverage = providerEntries
|
|
546
|
+
.map((provider) => ({
|
|
547
|
+
provider,
|
|
548
|
+
value: getProviderCacheCoverageMetrics(provider).cacheCoverage,
|
|
549
|
+
}))
|
|
550
|
+
.filter((entry) => entry.value !== undefined)
|
|
551
|
+
.sort((a, b) => b.value - a.value)[0];
|
|
552
|
+
if (bestCacheCoverage) {
|
|
553
|
+
lines.push(`- Best Cache Coverage: ${providerLabel(bestCacheCoverage.provider.providerID)} (${formatPercent(bestCacheCoverage.value, 1)})`);
|
|
554
|
+
}
|
|
555
|
+
const bestCacheReadCoverage = providerEntries
|
|
556
|
+
.map((provider) => ({
|
|
557
|
+
provider,
|
|
558
|
+
value: getProviderCacheCoverageMetrics(provider).cacheReadCoverage,
|
|
559
|
+
}))
|
|
560
|
+
.filter((entry) => entry.value !== undefined)
|
|
561
|
+
.sort((a, b) => b.value - a.value)[0];
|
|
562
|
+
if (bestCacheReadCoverage) {
|
|
563
|
+
lines.push(`- Best Cache Read Coverage: ${providerLabel(bestCacheReadCoverage.provider.providerID)} (${formatPercent(bestCacheReadCoverage.value, 1)})`);
|
|
564
|
+
}
|
|
565
|
+
const highestMeasured = providerEntries
|
|
566
|
+
.filter((provider) => measuredCostCell(provider.providerID, provider.cost) !== '-')
|
|
567
|
+
.sort((a, b) => b.cost - a.cost)[0];
|
|
568
|
+
if (highestMeasured && highestMeasured.cost > 0) {
|
|
569
|
+
lines.push(`- Highest measured cost: ${providerLabel(highestMeasured.providerID)} (${formatUsd(highestMeasured.cost)})`);
|
|
570
|
+
}
|
|
571
|
+
return lines;
|
|
572
|
+
};
|
|
573
|
+
const providerRows = providerEntries
|
|
504
574
|
.map((provider) => {
|
|
505
575
|
const providerID = mdCell(provider.providerID);
|
|
506
576
|
return showCost
|
|
507
|
-
? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
|
|
577
|
+
? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${cacheCoverageCell(provider)} | ${cacheReadCoverageCell(provider)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
|
|
508
578
|
: `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
|
|
509
579
|
});
|
|
510
580
|
const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
|
|
@@ -516,9 +586,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
516
586
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
517
587
|
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
518
588
|
}
|
|
519
|
-
const remaining = win.remainingPercent
|
|
520
|
-
? '-'
|
|
521
|
-
: `${win.remainingPercent.toFixed(1)}%`;
|
|
589
|
+
const remaining = formatQuotaPercent(win.remainingPercent);
|
|
522
590
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
523
591
|
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
524
592
|
});
|
|
@@ -537,9 +605,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
537
605
|
mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
538
606
|
];
|
|
539
607
|
}
|
|
540
|
-
const remaining = quota.remainingPercent
|
|
541
|
-
? '-'
|
|
542
|
-
: `${quota.remainingPercent.toFixed(1)}%`;
|
|
608
|
+
const remaining = formatQuotaPercent(quota.remainingPercent);
|
|
543
609
|
return [
|
|
544
610
|
mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
545
611
|
];
|
|
@@ -564,17 +630,20 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
564
630
|
`- API cost: ${apiCostSummaryValue()}`,
|
|
565
631
|
]
|
|
566
632
|
: []),
|
|
633
|
+
...(highlightLines().length > 0
|
|
634
|
+
? ['', '### Highlights', ...highlightLines()]
|
|
635
|
+
: []),
|
|
567
636
|
'',
|
|
568
637
|
'### Usage by Provider',
|
|
569
638
|
showCost
|
|
570
|
-
? '| Provider | Input | Output | Cache | Total | Measured Cost | API Cost |'
|
|
639
|
+
? '| Provider | Input | Output | Cache | Total | Cache Coverage | Cache Read Coverage | Measured Cost | API Cost |'
|
|
571
640
|
: '| Provider | Input | Output | Cache | Total |',
|
|
572
641
|
showCost
|
|
573
|
-
? '
|
|
642
|
+
? '|---|---:|---:|---:|---:|---:|---:|---:|---:|'
|
|
574
643
|
: '|---|---:|---:|---:|---:|',
|
|
575
644
|
...(providerRows.length
|
|
576
645
|
? providerRows
|
|
577
|
-
: [showCost ? '| - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
|
|
646
|
+
: [showCost ? '| - | - | - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
|
|
578
647
|
'',
|
|
579
648
|
'### Subscription Quota',
|
|
580
649
|
...(quotaLines.length
|
|
@@ -638,14 +707,35 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
638
707
|
lines.push(fitLine(hasAnyUsage ? ' N/A (Copilot)' : ' -', width));
|
|
639
708
|
}
|
|
640
709
|
}
|
|
710
|
+
const providerCachePairs = Object.values(usage.providers)
|
|
711
|
+
.map((provider) => {
|
|
712
|
+
const metrics = getProviderCacheCoverageMetrics(provider);
|
|
713
|
+
const parts = [];
|
|
714
|
+
if (metrics.cacheCoverage !== undefined) {
|
|
715
|
+
parts.push(`Cov ${formatPercent(metrics.cacheCoverage, 1)}`);
|
|
716
|
+
}
|
|
717
|
+
if (metrics.cacheReadCoverage !== undefined) {
|
|
718
|
+
parts.push(`Read ${formatPercent(metrics.cacheReadCoverage, 1)}`);
|
|
719
|
+
}
|
|
720
|
+
if (parts.length === 0)
|
|
721
|
+
return undefined;
|
|
722
|
+
return {
|
|
723
|
+
label: displayShortLabel(provider.providerID),
|
|
724
|
+
value: parts.join(' '),
|
|
725
|
+
};
|
|
726
|
+
})
|
|
727
|
+
.filter((item) => Boolean(item));
|
|
728
|
+
if (providerCachePairs.length > 0) {
|
|
729
|
+
lines.push('');
|
|
730
|
+
lines.push(fitLine('Provider Cache', width));
|
|
731
|
+
lines.push(...alignPairs(providerCachePairs).map((line) => fitLine(line, width)));
|
|
732
|
+
}
|
|
641
733
|
const quotaPairs = collapseQuotaSnapshots(quotas).flatMap((item) => {
|
|
642
734
|
if (item.status === 'ok') {
|
|
643
735
|
if (item.windows && item.windows.length > 0) {
|
|
644
736
|
const pairs = item.windows.map((win, idx) => {
|
|
645
737
|
const showPercent = win.showPercent !== false;
|
|
646
|
-
const pct = win.remainingPercent
|
|
647
|
-
? '-'
|
|
648
|
-
: `${win.remainingPercent.toFixed(1)}%`;
|
|
738
|
+
const pct = formatQuotaPercent(win.remainingPercent);
|
|
649
739
|
const reset = compactReset(win.resetAt, win.resetLabel, win.label);
|
|
650
740
|
const parts = [win.label];
|
|
651
741
|
if (showPercent)
|
|
@@ -673,9 +763,7 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
673
763
|
},
|
|
674
764
|
];
|
|
675
765
|
}
|
|
676
|
-
const percent = item.remainingPercent
|
|
677
|
-
? '-'
|
|
678
|
-
: `${item.remainingPercent.toFixed(1)}%`;
|
|
766
|
+
const percent = formatQuotaPercent(item.remainingPercent);
|
|
679
767
|
const reset = compactReset(item.resetAt, 'Rst');
|
|
680
768
|
return [
|
|
681
769
|
{
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { createPersistenceScheduler } from './persistence.js';
|
|
|
12
12
|
import { createQuotaService } from './quota_service.js';
|
|
13
13
|
import { createUsageService } from './usage_service.js';
|
|
14
14
|
import { createTitleApplicator } from './title_apply.js';
|
|
15
|
+
const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
|
|
16
|
+
const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
|
|
15
17
|
export async function QuotaSidebarPlugin(input) {
|
|
16
18
|
const quotaRuntime = createQuotaRuntime();
|
|
17
19
|
const configDir = resolveOpencodeConfigDir();
|
|
@@ -164,18 +166,61 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
164
166
|
restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
|
|
165
167
|
});
|
|
166
168
|
const titleRefresh = createTitleRefreshScheduler({
|
|
167
|
-
apply:
|
|
169
|
+
apply: async (sessionID) => {
|
|
170
|
+
await titleApplicator.applyTitle(sessionID);
|
|
171
|
+
},
|
|
168
172
|
onError: swallow('titleRefresh'),
|
|
169
173
|
});
|
|
170
174
|
scheduleTitleRefresh = titleRefresh.schedule;
|
|
171
175
|
const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
|
|
172
176
|
const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
|
|
173
177
|
const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
|
|
178
|
+
let startupTitleWork = Promise.resolve();
|
|
179
|
+
const runStartupRestore = async (attempt = 0) => {
|
|
180
|
+
const result = await restoreAllVisibleTitles({
|
|
181
|
+
abortIfEnabled: config.sidebar.enabled,
|
|
182
|
+
});
|
|
183
|
+
if (result.restored === result.attempted)
|
|
184
|
+
return;
|
|
185
|
+
debug(`startup restore incomplete: restored ${result.restored}/${result.attempted} touched titles while display mode remains OFF`);
|
|
186
|
+
if (state.titleEnabled || config.sidebar.enabled === false)
|
|
187
|
+
return;
|
|
188
|
+
if (attempt >= 2)
|
|
189
|
+
return;
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
191
|
+
await runStartupRestore(attempt + 1);
|
|
192
|
+
};
|
|
174
193
|
if (!state.titleEnabled || !config.sidebar.enabled) {
|
|
175
|
-
|
|
194
|
+
startupTitleWork = runStartupRestore().catch(swallow('startup:restoreAllVisibleTitles'));
|
|
176
195
|
}
|
|
177
196
|
else {
|
|
178
|
-
|
|
197
|
+
startupTitleWork = refreshAllTouchedTitles()
|
|
198
|
+
.then(() => undefined)
|
|
199
|
+
.catch(swallow('startup:refreshAllTouchedTitles'));
|
|
200
|
+
}
|
|
201
|
+
const shutdown = async () => {
|
|
202
|
+
await Promise.race([
|
|
203
|
+
startupTitleWork,
|
|
204
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
205
|
+
]).catch(swallow('shutdown:startupTitleWork'));
|
|
206
|
+
await titleRefresh.waitForQuiescence().catch(swallow('shutdown:titleQuiescence'));
|
|
207
|
+
await flushSave().catch(swallow('shutdown:flushSave'));
|
|
208
|
+
};
|
|
209
|
+
const processWithHook = process;
|
|
210
|
+
const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||= new Set());
|
|
211
|
+
shutdownCallbacks.add(shutdown);
|
|
212
|
+
if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
|
|
213
|
+
processWithHook[SHUTDOWN_HOOK_KEY] = true;
|
|
214
|
+
process.once('beforeExit', () => {
|
|
215
|
+
void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback()));
|
|
216
|
+
});
|
|
217
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
218
|
+
process.once(signal, () => {
|
|
219
|
+
void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback())).finally(() => {
|
|
220
|
+
process.kill(process.pid, signal);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
179
224
|
}
|
|
180
225
|
const showToast = async (period, message) => {
|
|
181
226
|
await input.client.tui
|
|
@@ -268,9 +313,12 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
268
313
|
},
|
|
269
314
|
scheduleSave,
|
|
270
315
|
flushSave,
|
|
316
|
+
waitForStartupTitleWork: () => startupTitleWork,
|
|
271
317
|
refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
|
|
272
318
|
cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
|
|
319
|
+
flushScheduledTitleRefreshes: () => titleRefresh.flushScheduled(),
|
|
273
320
|
waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
|
|
321
|
+
waitForTitleRefreshQuiescence: () => titleRefresh.waitForQuiescence(),
|
|
274
322
|
restoreAllVisibleTitles,
|
|
275
323
|
refreshAllTouchedTitles,
|
|
276
324
|
refreshAllVisibleTitles,
|
|
@@ -279,7 +327,10 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
279
327
|
getQuotaSnapshots,
|
|
280
328
|
renderMarkdownReport,
|
|
281
329
|
renderToastMessage,
|
|
282
|
-
config
|
|
330
|
+
config: {
|
|
331
|
+
sidebar: config.sidebar,
|
|
332
|
+
sidebarEnabled: config.sidebar.enabled,
|
|
333
|
+
},
|
|
283
334
|
}),
|
|
284
335
|
};
|
|
285
336
|
}
|
package/dist/persistence.js
CHANGED
|
@@ -4,6 +4,17 @@ export function createPersistenceScheduler(deps) {
|
|
|
4
4
|
let stateDirty = false;
|
|
5
5
|
let saveTimer;
|
|
6
6
|
let saveInFlight = Promise.resolve();
|
|
7
|
+
const scheduleRetry = (delayMs = 1_000) => {
|
|
8
|
+
if (saveTimer)
|
|
9
|
+
return;
|
|
10
|
+
saveTimer = setTimeout(() => {
|
|
11
|
+
saveTimer = undefined;
|
|
12
|
+
void persist().catch((error) => {
|
|
13
|
+
debug(`persistState retry failed: ${String(error)}`);
|
|
14
|
+
scheduleRetry(Math.min(delayMs * 2, 10_000));
|
|
15
|
+
});
|
|
16
|
+
}, delayMs);
|
|
17
|
+
};
|
|
7
18
|
/**
|
|
8
19
|
* Capture and delete specific dirty keys instead of clearing the whole set.
|
|
9
20
|
* Keys added between capture and write completion are preserved.
|
|
@@ -34,7 +45,10 @@ export function createPersistenceScheduler(deps) {
|
|
|
34
45
|
clearTimeout(saveTimer);
|
|
35
46
|
saveTimer = setTimeout(() => {
|
|
36
47
|
saveTimer = undefined;
|
|
37
|
-
void persist().catch(
|
|
48
|
+
void persist().catch((error) => {
|
|
49
|
+
swallow('persistState:save')(error);
|
|
50
|
+
scheduleRetry();
|
|
51
|
+
});
|
|
38
52
|
}, 200);
|
|
39
53
|
};
|
|
40
54
|
const flushSave = async () => {
|
package/dist/quota_service.js
CHANGED
|
@@ -11,6 +11,7 @@ export function createQuotaService(deps) {
|
|
|
11
11
|
const authCache = new TtlValueCache();
|
|
12
12
|
const providerOptionsCache = new TtlValueCache();
|
|
13
13
|
const inFlight = new Map();
|
|
14
|
+
let lastSuccessfulProviderOptionsMap = {};
|
|
14
15
|
const authFingerprint = (auth) => {
|
|
15
16
|
if (!auth || typeof auth !== 'object')
|
|
16
17
|
return undefined;
|
|
@@ -46,7 +47,7 @@ export function createQuotaService(deps) {
|
|
|
46
47
|
if (cached)
|
|
47
48
|
return cached;
|
|
48
49
|
const value = await loadAuthMap(deps.authPath);
|
|
49
|
-
return authCache.set(value,
|
|
50
|
+
return authCache.set(value, 5_000);
|
|
50
51
|
};
|
|
51
52
|
const getProviderOptionsMap = async () => {
|
|
52
53
|
const cached = providerOptionsCache.get();
|
|
@@ -58,23 +59,127 @@ export function createQuotaService(deps) {
|
|
|
58
59
|
}
|
|
59
60
|
// Newer runtimes expose config.providers; older clients may only expose
|
|
60
61
|
// provider.list with a slightly different response shape.
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
let response;
|
|
63
|
+
let fromConfigProviders = false;
|
|
64
|
+
if (client.config?.providers) {
|
|
65
|
+
fromConfigProviders = true;
|
|
66
|
+
response = await client.config
|
|
67
|
+
.providers({
|
|
63
68
|
query: { directory: deps.directory },
|
|
64
69
|
throwOnError: true,
|
|
65
70
|
})
|
|
66
|
-
|
|
71
|
+
.catch(swallow('getProviderOptionsMap:configProviders'));
|
|
72
|
+
}
|
|
73
|
+
if (!response && client.provider?.list) {
|
|
74
|
+
response = await client.provider
|
|
75
|
+
.list({
|
|
67
76
|
query: { directory: deps.directory },
|
|
68
77
|
throwOnError: true,
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
})
|
|
79
|
+
.catch(swallow('getProviderOptionsMap:providerList'));
|
|
80
|
+
}
|
|
81
|
+
const data = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
|
|
82
|
+
? response.data
|
|
83
|
+
: undefined;
|
|
84
|
+
if (!response || data === undefined) {
|
|
85
|
+
if (client.provider?.list && fromConfigProviders) {
|
|
86
|
+
response = await client.provider
|
|
87
|
+
.list({
|
|
88
|
+
query: { directory: deps.directory },
|
|
89
|
+
throwOnError: true,
|
|
90
|
+
})
|
|
91
|
+
.catch(swallow('getProviderOptionsMap:providerListNoDataFallback'));
|
|
92
|
+
const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
|
|
93
|
+
? response.data
|
|
94
|
+
: undefined;
|
|
95
|
+
const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
|
|
96
|
+
const fallbackList = Array.isArray(fallbackRecord?.providers)
|
|
97
|
+
? fallbackRecord.providers
|
|
98
|
+
: Array.isArray(fallbackRecord?.all)
|
|
99
|
+
? fallbackRecord.all
|
|
100
|
+
: Array.isArray(fallbackData)
|
|
101
|
+
? fallbackData
|
|
102
|
+
: undefined;
|
|
103
|
+
const map = Array.isArray(fallbackList)
|
|
104
|
+
? fallbackList.reduce((acc, item) => {
|
|
105
|
+
if (!item || typeof item !== 'object')
|
|
106
|
+
return acc;
|
|
107
|
+
const record = item;
|
|
108
|
+
const id = record.id;
|
|
109
|
+
const options = record.options;
|
|
110
|
+
if (typeof id !== 'string')
|
|
111
|
+
return acc;
|
|
112
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
113
|
+
acc[id] = {};
|
|
114
|
+
return acc;
|
|
115
|
+
}
|
|
116
|
+
acc[id] = options;
|
|
117
|
+
return acc;
|
|
118
|
+
}, {})
|
|
119
|
+
: {};
|
|
120
|
+
if (Object.keys(map).length > 0) {
|
|
121
|
+
lastSuccessfulProviderOptionsMap = map;
|
|
122
|
+
return providerOptionsCache.set(map, 5_000);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
|
|
126
|
+
? lastSuccessfulProviderOptionsMap
|
|
127
|
+
: {};
|
|
128
|
+
}
|
|
129
|
+
const dataRecord = isRecord(data) ? data : undefined;
|
|
130
|
+
const list = Array.isArray(dataRecord?.providers)
|
|
131
|
+
? dataRecord.providers
|
|
132
|
+
: Array.isArray(dataRecord?.all)
|
|
133
|
+
? dataRecord.all
|
|
75
134
|
: Array.isArray(data)
|
|
76
135
|
? data
|
|
77
136
|
: undefined;
|
|
137
|
+
if (!list && fromConfigProviders && client.provider?.list) {
|
|
138
|
+
response = await client.provider
|
|
139
|
+
.list({
|
|
140
|
+
query: { directory: deps.directory },
|
|
141
|
+
throwOnError: true,
|
|
142
|
+
})
|
|
143
|
+
.catch(swallow('getProviderOptionsMap:providerListFallback'));
|
|
144
|
+
const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
|
|
145
|
+
? response.data
|
|
146
|
+
: undefined;
|
|
147
|
+
const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
|
|
148
|
+
const fallbackList = Array.isArray(fallbackRecord?.providers)
|
|
149
|
+
? fallbackRecord.providers
|
|
150
|
+
: Array.isArray(fallbackRecord?.all)
|
|
151
|
+
? fallbackRecord.all
|
|
152
|
+
: Array.isArray(fallbackData)
|
|
153
|
+
? fallbackData
|
|
154
|
+
: undefined;
|
|
155
|
+
const map = Array.isArray(fallbackList)
|
|
156
|
+
? fallbackList.reduce((acc, item) => {
|
|
157
|
+
if (!item || typeof item !== 'object')
|
|
158
|
+
return acc;
|
|
159
|
+
const record = item;
|
|
160
|
+
const id = record.id;
|
|
161
|
+
const options = record.options;
|
|
162
|
+
if (typeof id !== 'string')
|
|
163
|
+
return acc;
|
|
164
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
165
|
+
acc[id] = {};
|
|
166
|
+
return acc;
|
|
167
|
+
}
|
|
168
|
+
acc[id] = options;
|
|
169
|
+
return acc;
|
|
170
|
+
}, {})
|
|
171
|
+
: {};
|
|
172
|
+
if (Object.keys(map).length > 0) {
|
|
173
|
+
lastSuccessfulProviderOptionsMap = map;
|
|
174
|
+
return providerOptionsCache.set(map, 5_000);
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(fallbackList)) {
|
|
177
|
+
return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
|
|
178
|
+
? lastSuccessfulProviderOptionsMap
|
|
179
|
+
: {};
|
|
180
|
+
}
|
|
181
|
+
return providerOptionsCache.set(map, 5_000);
|
|
182
|
+
}
|
|
78
183
|
const map = Array.isArray(list)
|
|
79
184
|
? list.reduce((acc, item) => {
|
|
80
185
|
if (!item || typeof item !== 'object')
|
|
@@ -94,6 +199,15 @@ export function createQuotaService(deps) {
|
|
|
94
199
|
return acc;
|
|
95
200
|
}, {})
|
|
96
201
|
: {};
|
|
202
|
+
if (Object.keys(map).length > 0) {
|
|
203
|
+
lastSuccessfulProviderOptionsMap = map;
|
|
204
|
+
return providerOptionsCache.set(map, 5_000);
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(list)) {
|
|
207
|
+
return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
|
|
208
|
+
? lastSuccessfulProviderOptionsMap
|
|
209
|
+
: providerOptionsCache.set(map, 5_000);
|
|
210
|
+
}
|
|
97
211
|
return providerOptionsCache.set(map, 5_000);
|
|
98
212
|
};
|
|
99
213
|
const isValidQuotaCache = (snapshot) => {
|
|
@@ -276,7 +390,11 @@ export function createQuotaService(deps) {
|
|
|
276
390
|
body: next,
|
|
277
391
|
throwOnError: true,
|
|
278
392
|
})
|
|
279
|
-
.catch(
|
|
393
|
+
.catch((error) => {
|
|
394
|
+
swallow('getQuotaSnapshots:authSet')(error);
|
|
395
|
+
throw error;
|
|
396
|
+
});
|
|
397
|
+
authCache.clear();
|
|
280
398
|
}, providerOptions)
|
|
281
399
|
.then((latest) => {
|
|
282
400
|
if (!latest)
|
package/dist/storage_parse.js
CHANGED
|
@@ -27,6 +27,7 @@ function parseProviderUsage(value) {
|
|
|
27
27
|
cost: asNumber(value.cost, 0),
|
|
28
28
|
apiCost: asNumber(value.apiCost, 0),
|
|
29
29
|
assistantMessages: asNumber(value.assistantMessages, 0),
|
|
30
|
+
cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
function parseCacheUsageBucket(value) {
|
package/dist/title_apply.d.ts
CHANGED
|
@@ -17,16 +17,32 @@ export declare function createTitleApplicator(deps: {
|
|
|
17
17
|
scheduleParentRefreshIfSafe: (sessionID: string, parentID?: string) => void;
|
|
18
18
|
restoreConcurrency: number;
|
|
19
19
|
}): {
|
|
20
|
-
applyTitle: (sessionID: string) => Promise<
|
|
20
|
+
applyTitle: (sessionID: string) => Promise<boolean>;
|
|
21
21
|
handleSessionUpdatedTitle: (args: {
|
|
22
22
|
sessionID: string;
|
|
23
23
|
incomingTitle: string;
|
|
24
24
|
sessionState: SessionState;
|
|
25
25
|
scheduleRefresh: (sessionID: string, delay?: number) => void;
|
|
26
26
|
}) => Promise<void>;
|
|
27
|
-
restoreSessionTitle: (sessionID: string
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
restoreSessionTitle: (sessionID: string, options?: {
|
|
28
|
+
abortIfEnabled?: boolean;
|
|
29
|
+
}) => Promise<boolean>;
|
|
30
|
+
restoreAllVisibleTitles: (options?: {
|
|
31
|
+
abortIfEnabled?: boolean;
|
|
32
|
+
}) => Promise<{
|
|
33
|
+
attempted: number;
|
|
34
|
+
restored: number;
|
|
35
|
+
listFailed: boolean;
|
|
36
|
+
}>;
|
|
37
|
+
refreshAllTouchedTitles: () => Promise<{
|
|
38
|
+
attempted: number;
|
|
39
|
+
refreshed: number;
|
|
40
|
+
listFailed: boolean;
|
|
41
|
+
}>;
|
|
42
|
+
refreshAllVisibleTitles: () => Promise<{
|
|
43
|
+
attempted: number;
|
|
44
|
+
refreshed: number;
|
|
45
|
+
listFailed: boolean;
|
|
46
|
+
}>;
|
|
31
47
|
forgetSession: (sessionID: string) => void;
|
|
32
48
|
};
|
package/dist/title_apply.js
CHANGED
|
@@ -2,12 +2,14 @@ import { canonicalizeTitle, canonicalizeTitleForCompare, looksDecorated, } from
|
|
|
2
2
|
import { swallow, debug, mapConcurrent } from './helpers.js';
|
|
3
3
|
export function createTitleApplicator(deps) {
|
|
4
4
|
const pendingAppliedTitle = new Map();
|
|
5
|
+
const recentRestore = new Map();
|
|
5
6
|
const forgetSession = (sessionID) => {
|
|
6
7
|
pendingAppliedTitle.delete(sessionID);
|
|
8
|
+
recentRestore.delete(sessionID);
|
|
7
9
|
};
|
|
8
10
|
const applyTitle = async (sessionID) => {
|
|
9
11
|
if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
|
|
10
|
-
return;
|
|
12
|
+
return false;
|
|
11
13
|
let stateMutated = false;
|
|
12
14
|
const session = await deps.client.session
|
|
13
15
|
.get({
|
|
@@ -17,7 +19,14 @@ export function createTitleApplicator(deps) {
|
|
|
17
19
|
})
|
|
18
20
|
.catch(swallow('applyTitle:getSession'));
|
|
19
21
|
if (!session)
|
|
20
|
-
return;
|
|
22
|
+
return false;
|
|
23
|
+
if (!session.data ||
|
|
24
|
+
typeof session.data.title !== 'string' ||
|
|
25
|
+
!session.data.time ||
|
|
26
|
+
typeof session.data.time.created !== 'number') {
|
|
27
|
+
debug(`applyTitle skipped malformed session payload for ${sessionID}`);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
21
30
|
const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
|
|
22
31
|
// Detect whether the current title is our own decorated form.
|
|
23
32
|
const currentTitle = session.data.title;
|
|
@@ -62,7 +71,7 @@ export function createTitleApplicator(deps) {
|
|
|
62
71
|
: [];
|
|
63
72
|
const nextTitle = deps.renderSidebarTitle(sessionState.baseTitle, usage, quotas, deps.config);
|
|
64
73
|
if (!deps.config.sidebar.enabled || !deps.state.titleEnabled)
|
|
65
|
-
return;
|
|
74
|
+
return false;
|
|
66
75
|
if (canonicalizeTitleForCompare(nextTitle) ===
|
|
67
76
|
canonicalizeTitleForCompare(session.data.title)) {
|
|
68
77
|
if (looksDecorated(session.data.title)) {
|
|
@@ -76,7 +85,7 @@ export function createTitleApplicator(deps) {
|
|
|
76
85
|
}
|
|
77
86
|
deps.scheduleSave();
|
|
78
87
|
deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
|
|
79
|
-
return;
|
|
88
|
+
return true;
|
|
80
89
|
}
|
|
81
90
|
// Mark pending title to ignore the immediate echo `session.updated` event.
|
|
82
91
|
// H3 fix: use longer TTL (15s) and add decoration detection as backup.
|
|
@@ -100,11 +109,12 @@ export function createTitleApplicator(deps) {
|
|
|
100
109
|
sessionState.lastAppliedTitle = previousApplied;
|
|
101
110
|
deps.scheduleSave();
|
|
102
111
|
deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
|
|
103
|
-
return;
|
|
112
|
+
return false;
|
|
104
113
|
}
|
|
105
114
|
pendingAppliedTitle.delete(sessionID);
|
|
106
115
|
deps.scheduleSave();
|
|
107
116
|
deps.scheduleParentRefreshIfSafe(sessionID, sessionState.parentID);
|
|
117
|
+
return true;
|
|
108
118
|
};
|
|
109
119
|
const handleSessionUpdatedTitle = async (args) => {
|
|
110
120
|
const pending = pendingAppliedTitle.get(args.sessionID);
|
|
@@ -138,13 +148,32 @@ export function createTitleApplicator(deps) {
|
|
|
138
148
|
return;
|
|
139
149
|
}
|
|
140
150
|
}
|
|
151
|
+
if (looksDecorated(args.incomingTitle) && !args.sessionState.lastAppliedTitle) {
|
|
152
|
+
debug(`ignoring untracked decorated title for session ${args.sessionID}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const restored = recentRestore.get(args.sessionID);
|
|
156
|
+
if (restored) {
|
|
157
|
+
if (restored.expiresAt <= Date.now()) {
|
|
158
|
+
recentRestore.delete(args.sessionID);
|
|
159
|
+
}
|
|
160
|
+
else if (looksDecorated(args.incomingTitle) &&
|
|
161
|
+
(!restored.decoratedTitle ||
|
|
162
|
+
canonicalizeTitleForCompare(args.incomingTitle) ===
|
|
163
|
+
canonicalizeTitleForCompare(restored.decoratedTitle))) {
|
|
164
|
+
debug(`ignoring decorated echo after restore for session ${args.sessionID}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
141
168
|
args.sessionState.baseTitle = canonicalizeTitle(args.incomingTitle) || 'Session';
|
|
142
169
|
args.sessionState.lastAppliedTitle = undefined;
|
|
143
170
|
deps.markDirty(deps.state.sessionDateMap[args.sessionID]);
|
|
144
171
|
deps.scheduleSave();
|
|
145
172
|
args.scheduleRefresh(args.sessionID);
|
|
146
173
|
};
|
|
147
|
-
const restoreSessionTitle = async (sessionID) => {
|
|
174
|
+
const restoreSessionTitle = async (sessionID, options) => {
|
|
175
|
+
if (options?.abortIfEnabled && deps.state.titleEnabled)
|
|
176
|
+
return false;
|
|
148
177
|
const session = await deps.client.session
|
|
149
178
|
.get({
|
|
150
179
|
path: { id: sessionID },
|
|
@@ -153,7 +182,14 @@ export function createTitleApplicator(deps) {
|
|
|
153
182
|
})
|
|
154
183
|
.catch(swallow('restoreSessionTitle:get'));
|
|
155
184
|
if (!session)
|
|
156
|
-
return;
|
|
185
|
+
return false;
|
|
186
|
+
if (!session.data ||
|
|
187
|
+
typeof session.data.title !== 'string' ||
|
|
188
|
+
!session.data.time ||
|
|
189
|
+
typeof session.data.time.created !== 'number') {
|
|
190
|
+
debug(`restoreSessionTitle skipped malformed session payload for ${sessionID}`);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
157
193
|
const sessionState = deps.ensureSessionState(sessionID, session.data.title, session.data.time.created, session.data.parentID ?? null);
|
|
158
194
|
const baseTitle = canonicalizeTitle(sessionState.baseTitle) || 'Session';
|
|
159
195
|
if (session.data.title === baseTitle) {
|
|
@@ -162,8 +198,10 @@ export function createTitleApplicator(deps) {
|
|
|
162
198
|
deps.markDirty(deps.state.sessionDateMap[sessionID]);
|
|
163
199
|
deps.scheduleSave();
|
|
164
200
|
}
|
|
165
|
-
return;
|
|
201
|
+
return true;
|
|
166
202
|
}
|
|
203
|
+
if (options?.abortIfEnabled && deps.state.titleEnabled)
|
|
204
|
+
return false;
|
|
167
205
|
const updated = await deps.client.session
|
|
168
206
|
.update({
|
|
169
207
|
path: { id: sessionID },
|
|
@@ -173,26 +211,39 @@ export function createTitleApplicator(deps) {
|
|
|
173
211
|
})
|
|
174
212
|
.catch(swallow('restoreSessionTitle:update'));
|
|
175
213
|
if (!updated)
|
|
176
|
-
return;
|
|
214
|
+
return false;
|
|
215
|
+
pendingAppliedTitle.delete(sessionID);
|
|
216
|
+
recentRestore.set(sessionID, {
|
|
217
|
+
baseTitle,
|
|
218
|
+
decoratedTitle: sessionState.lastAppliedTitle,
|
|
219
|
+
expiresAt: Date.now() + 15_000,
|
|
220
|
+
});
|
|
177
221
|
sessionState.lastAppliedTitle = undefined;
|
|
178
222
|
deps.markDirty(deps.state.sessionDateMap[sessionID]);
|
|
179
223
|
deps.scheduleSave();
|
|
224
|
+
return true;
|
|
180
225
|
};
|
|
181
|
-
const restoreAllVisibleTitles = async () => {
|
|
226
|
+
const restoreAllVisibleTitles = async (options) => {
|
|
182
227
|
const touched = Object.entries(deps.state.sessions)
|
|
183
228
|
.filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
|
|
184
229
|
.map(([sessionID]) => sessionID);
|
|
185
|
-
await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) =>
|
|
186
|
-
|
|
187
|
-
|
|
230
|
+
const results = await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => restoreSessionTitle(sessionID, options));
|
|
231
|
+
return {
|
|
232
|
+
attempted: touched.length,
|
|
233
|
+
restored: results.filter(Boolean).length,
|
|
234
|
+
listFailed: false,
|
|
235
|
+
};
|
|
188
236
|
};
|
|
189
237
|
const refreshAllTouchedTitles = async () => {
|
|
190
238
|
const touched = Object.entries(deps.state.sessions)
|
|
191
239
|
.filter(([, sessionState]) => Boolean(sessionState.lastAppliedTitle))
|
|
192
240
|
.map(([sessionID]) => sessionID);
|
|
193
|
-
await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) =>
|
|
194
|
-
|
|
195
|
-
|
|
241
|
+
const results = await mapConcurrent(touched, deps.restoreConcurrency, async (sessionID) => applyTitle(sessionID));
|
|
242
|
+
return {
|
|
243
|
+
attempted: touched.length,
|
|
244
|
+
refreshed: results.filter(Boolean).length,
|
|
245
|
+
listFailed: false,
|
|
246
|
+
};
|
|
196
247
|
};
|
|
197
248
|
const refreshAllVisibleTitles = async () => {
|
|
198
249
|
const list = await deps.client.session
|
|
@@ -201,11 +252,16 @@ export function createTitleApplicator(deps) {
|
|
|
201
252
|
throwOnError: true,
|
|
202
253
|
})
|
|
203
254
|
.catch(swallow('refreshAllVisibleTitles:list'));
|
|
204
|
-
if (!list?.data)
|
|
205
|
-
return;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
255
|
+
if (!list?.data || !Array.isArray(list.data)) {
|
|
256
|
+
return { attempted: 0, refreshed: 0, listFailed: true };
|
|
257
|
+
}
|
|
258
|
+
const sessions = list.data.filter((session) => Boolean(session && typeof session.id === 'string'));
|
|
259
|
+
const results = await mapConcurrent(sessions, deps.restoreConcurrency, async (session) => applyTitle(session.id));
|
|
260
|
+
return {
|
|
261
|
+
attempted: sessions.length,
|
|
262
|
+
refreshed: results.filter(Boolean).length,
|
|
263
|
+
listFailed: false,
|
|
264
|
+
};
|
|
209
265
|
};
|
|
210
266
|
return {
|
|
211
267
|
applyTitle,
|
package/dist/title_refresh.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare function createTitleRefreshScheduler(options: {
|
|
|
6
6
|
apply: (sessionID: string) => Promise<void>;
|
|
7
7
|
cancel: (sessionID: string) => void;
|
|
8
8
|
cancelAll: () => void;
|
|
9
|
-
|
|
9
|
+
flushScheduled: () => Promise<void>;
|
|
10
|
+
waitForIdle: (timeoutMs?: number) => Promise<void>;
|
|
11
|
+
waitForQuiescence: (budgetMs?: number) => Promise<void>;
|
|
10
12
|
dispose: () => void;
|
|
11
13
|
};
|
package/dist/title_refresh.js
CHANGED
|
@@ -36,11 +36,32 @@ export function createTitleRefreshScheduler(options) {
|
|
|
36
36
|
clearTimeout(timer);
|
|
37
37
|
refreshTimer.clear();
|
|
38
38
|
};
|
|
39
|
-
const
|
|
39
|
+
const flushScheduled = async () => {
|
|
40
|
+
const pending = Array.from(refreshTimer.keys());
|
|
41
|
+
cancelAll();
|
|
42
|
+
await Promise.allSettled(pending.map((sessionID) => applyLocked(sessionID)));
|
|
43
|
+
};
|
|
44
|
+
const waitForIdle = async (timeoutMs) => {
|
|
40
45
|
const inflight = Array.from(applyLocks.values());
|
|
41
46
|
if (inflight.length === 0)
|
|
42
47
|
return;
|
|
43
|
-
|
|
48
|
+
if (timeoutMs === undefined) {
|
|
49
|
+
await Promise.allSettled(inflight);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await Promise.race([
|
|
53
|
+
Promise.allSettled(inflight),
|
|
54
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
55
|
+
]);
|
|
56
|
+
};
|
|
57
|
+
const waitForQuiescence = async (budgetMs = 10_000) => {
|
|
58
|
+
const deadline = Date.now() + budgetMs;
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
await flushScheduled();
|
|
61
|
+
await waitForIdle(Math.max(0, deadline - Date.now()));
|
|
62
|
+
if (refreshTimer.size === 0 && applyLocks.size === 0)
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
44
65
|
};
|
|
45
66
|
const dispose = () => {
|
|
46
67
|
cancelAll();
|
|
@@ -51,7 +72,9 @@ export function createTitleRefreshScheduler(options) {
|
|
|
51
72
|
apply: applyLocked,
|
|
52
73
|
cancel,
|
|
53
74
|
cancelAll,
|
|
75
|
+
flushScheduled,
|
|
54
76
|
waitForIdle,
|
|
77
|
+
waitForQuiescence,
|
|
55
78
|
dispose,
|
|
56
79
|
};
|
|
57
80
|
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -5,12 +5,27 @@ export declare function createQuotaSidebarTools(deps: {
|
|
|
5
5
|
setTitleEnabled: (enabled: boolean) => void;
|
|
6
6
|
scheduleSave: () => void;
|
|
7
7
|
flushSave: () => Promise<void>;
|
|
8
|
+
waitForStartupTitleWork: () => Promise<void>;
|
|
8
9
|
refreshSessionTitle: (sessionID: string, delay?: number) => void;
|
|
9
10
|
cancelAllTitleRefreshes: () => void;
|
|
11
|
+
flushScheduledTitleRefreshes: () => Promise<void>;
|
|
10
12
|
waitForTitleRefreshIdle: () => Promise<void>;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
waitForTitleRefreshQuiescence: () => Promise<void>;
|
|
14
|
+
restoreAllVisibleTitles: () => Promise<{
|
|
15
|
+
attempted: number;
|
|
16
|
+
restored: number;
|
|
17
|
+
listFailed: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
refreshAllTouchedTitles: () => Promise<{
|
|
20
|
+
attempted: number;
|
|
21
|
+
refreshed: number;
|
|
22
|
+
listFailed: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
refreshAllVisibleTitles: () => Promise<{
|
|
25
|
+
attempted: number;
|
|
26
|
+
refreshed: number;
|
|
27
|
+
listFailed: boolean;
|
|
28
|
+
}>;
|
|
14
29
|
showToast: (period: 'session' | 'day' | 'week' | 'month' | 'toggle', message: string) => Promise<void>;
|
|
15
30
|
summarizeForTool: (period: 'session' | 'day' | 'week' | 'month', sessionID: string, includeChildren: boolean) => Promise<UsageSummary>;
|
|
16
31
|
getQuotaSnapshots: (providerIDs: string[], options?: {
|
|
@@ -29,6 +44,7 @@ export declare function createQuotaSidebarTools(deps: {
|
|
|
29
44
|
width: number;
|
|
30
45
|
includeChildren: boolean;
|
|
31
46
|
};
|
|
47
|
+
sidebarEnabled: boolean;
|
|
32
48
|
};
|
|
33
49
|
}): {
|
|
34
50
|
quota_summary: {
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
2
|
const z = tool.schema;
|
|
3
3
|
export function createQuotaSidebarTools(deps) {
|
|
4
|
+
let toggleLock = Promise.resolve();
|
|
5
|
+
const waitForStartupTitleWork = async () => {
|
|
6
|
+
const timedOut = await Promise.race([
|
|
7
|
+
deps.waitForStartupTitleWork(),
|
|
8
|
+
new Promise((resolve) => setTimeout(() => resolve('timeout'), 3_000)),
|
|
9
|
+
]);
|
|
10
|
+
return timedOut === 'timeout';
|
|
11
|
+
};
|
|
4
12
|
return {
|
|
5
13
|
quota_summary: tool({
|
|
6
14
|
description: 'Show usage and quota summary for session/day/week/month.',
|
|
@@ -42,25 +50,59 @@ export function createQuotaSidebarTools(deps) {
|
|
|
42
50
|
.describe('Explicit on/off. Omit to toggle current state.'),
|
|
43
51
|
},
|
|
44
52
|
execute: async (args, context) => {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
const run = async () => {
|
|
54
|
+
const current = deps.getTitleEnabled();
|
|
55
|
+
const next = args.enabled !== undefined ? args.enabled : !current;
|
|
56
|
+
if (next) {
|
|
57
|
+
if (!deps.config.sidebarEnabled) {
|
|
58
|
+
return 'Sidebar usage display cannot be enabled because `sidebar.enabled=false` in config. Re-enable the sidebar feature first.';
|
|
59
|
+
}
|
|
60
|
+
const startupTimedOut = await waitForStartupTitleWork();
|
|
61
|
+
deps.setTitleEnabled(true);
|
|
62
|
+
deps.scheduleSave();
|
|
63
|
+
await deps.flushSave();
|
|
64
|
+
const visible = await deps.refreshAllVisibleTitles();
|
|
65
|
+
const touched = await deps.refreshAllTouchedTitles();
|
|
66
|
+
deps.refreshSessionTitle(context.sessionID, 0);
|
|
67
|
+
if (startupTimedOut) {
|
|
68
|
+
void deps.waitForStartupTitleWork().then(() => {
|
|
69
|
+
if (!deps.getTitleEnabled())
|
|
70
|
+
return;
|
|
71
|
+
void deps.refreshAllVisibleTitles();
|
|
72
|
+
void deps.refreshAllTouchedTitles();
|
|
73
|
+
deps.refreshSessionTitle(context.sessionID, 0);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
await deps.showToast('toggle', 'Sidebar usage display: ON');
|
|
77
|
+
if (visible.listFailed ||
|
|
78
|
+
visible.refreshed < visible.attempted ||
|
|
79
|
+
touched.refreshed < touched.attempted) {
|
|
80
|
+
return 'Sidebar usage display is now ON. Visible-session refresh failed, so only touched/current session titles are guaranteed to refresh immediately.';
|
|
81
|
+
}
|
|
82
|
+
return 'Sidebar usage display is now ON. Visible session titles are refreshing to show token usage and quota.';
|
|
83
|
+
}
|
|
84
|
+
deps.setTitleEnabled(false);
|
|
85
|
+
deps.scheduleSave();
|
|
86
|
+
await deps.flushSave();
|
|
87
|
+
deps.cancelAllTitleRefreshes();
|
|
88
|
+
await deps.waitForTitleRefreshQuiescence();
|
|
89
|
+
const restore = await deps.restoreAllVisibleTitles();
|
|
90
|
+
if (restore.restored === restore.attempted) {
|
|
91
|
+
await deps.showToast('toggle', 'Sidebar usage display: OFF');
|
|
92
|
+
return 'Sidebar usage display is now OFF. Touched session titles were restored to base titles.';
|
|
93
|
+
}
|
|
94
|
+
deps.setTitleEnabled(true);
|
|
95
|
+
deps.scheduleSave();
|
|
96
|
+
await deps.flushSave();
|
|
52
97
|
await deps.refreshAllVisibleTitles();
|
|
53
98
|
await deps.refreshAllTouchedTitles();
|
|
54
99
|
deps.refreshSessionTitle(context.sessionID, 0);
|
|
55
|
-
await deps.showToast('toggle', 'Sidebar usage display:
|
|
56
|
-
return 'Sidebar usage display
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await deps.restoreAllVisibleTitles();
|
|
62
|
-
await deps.showToast('toggle', 'Sidebar usage display: OFF');
|
|
63
|
-
return 'Sidebar usage display is now OFF. Restore was attempted for touched session titles.';
|
|
100
|
+
await deps.showToast('toggle', 'Sidebar usage display: OFF failed');
|
|
101
|
+
return 'Sidebar usage display remains ON because some touched session titles could not be restored. Try again after the session service recovers.';
|
|
102
|
+
};
|
|
103
|
+
const pending = toggleLock.then(run, run);
|
|
104
|
+
toggleLock = pending.then(() => undefined, () => undefined);
|
|
105
|
+
return pending;
|
|
64
106
|
},
|
|
65
107
|
}),
|
|
66
108
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export type CachedProviderUsage = {
|
|
|
72
72
|
/** Equivalent API billing cost (USD) computed from model pricing. */
|
|
73
73
|
apiCost: number;
|
|
74
74
|
assistantMessages: number;
|
|
75
|
+
/** Provider-level cache coverage buckets grouped by model cache behavior. */
|
|
76
|
+
cacheBuckets?: CacheUsageBuckets;
|
|
75
77
|
};
|
|
76
78
|
export type CachedSessionUsage = {
|
|
77
79
|
/** Billing aggregation cache version for cost/apiCost refresh migrations. */
|
package/dist/usage.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export type ProviderUsage = {
|
|
|
19
19
|
cost: number;
|
|
20
20
|
apiCost: number;
|
|
21
21
|
assistantMessages: number;
|
|
22
|
+
cacheBuckets?: CacheUsageBuckets;
|
|
22
23
|
};
|
|
23
24
|
export type UsageSummary = {
|
|
24
25
|
input: number;
|
|
@@ -42,6 +43,7 @@ export type UsageOptions = {
|
|
|
42
43
|
classifyCacheMode?: (message: AssistantMessage) => CacheCoverageMode;
|
|
43
44
|
};
|
|
44
45
|
export declare function getCacheCoverageMetrics(usage: Pick<UsageSummary, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
|
|
46
|
+
export declare function getProviderCacheCoverageMetrics(usage: Pick<ProviderUsage, 'input' | 'cacheRead' | 'cacheWrite' | 'assistantMessages' | 'cacheBuckets'>): CacheCoverageMetrics;
|
|
45
47
|
export declare function emptyUsageSummary(): UsageSummary;
|
|
46
48
|
export declare function summarizeMessages(entries: Array<{
|
|
47
49
|
info: Message;
|
package/dist/usage.js
CHANGED
|
@@ -120,6 +120,9 @@ export function getCacheCoverageMetrics(usage) {
|
|
|
120
120
|
: undefined,
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
|
+
export function getProviderCacheCoverageMetrics(usage) {
|
|
124
|
+
return getCacheCoverageMetrics(usage);
|
|
125
|
+
}
|
|
123
126
|
export function emptyUsageSummary() {
|
|
124
127
|
return {
|
|
125
128
|
input: 0,
|
|
@@ -147,6 +150,7 @@ function emptyProviderUsage(providerID) {
|
|
|
147
150
|
cost: 0,
|
|
148
151
|
apiCost: 0,
|
|
149
152
|
assistantMessages: 0,
|
|
153
|
+
cacheBuckets: undefined,
|
|
150
154
|
};
|
|
151
155
|
}
|
|
152
156
|
function isAssistant(message) {
|
|
@@ -194,10 +198,14 @@ function addMessageUsage(target, message, options) {
|
|
|
194
198
|
if (cacheMode === 'read-only') {
|
|
195
199
|
const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
196
200
|
addMessageCacheUsage(buckets.readOnly, message);
|
|
201
|
+
const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
202
|
+
addMessageCacheUsage(providerBuckets.readOnly, message);
|
|
197
203
|
}
|
|
198
204
|
else if (cacheMode === 'read-write') {
|
|
199
205
|
const buckets = (target.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
200
206
|
addMessageCacheUsage(buckets.readWrite, message);
|
|
207
|
+
const providerBuckets = (provider.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
208
|
+
addMessageCacheUsage(providerBuckets.readWrite, message);
|
|
201
209
|
}
|
|
202
210
|
}
|
|
203
211
|
function completedTimeOf(message) {
|
|
@@ -445,6 +453,11 @@ export function mergeUsage(target, source, options) {
|
|
|
445
453
|
}
|
|
446
454
|
existing.apiCost += provider.apiCost;
|
|
447
455
|
existing.assistantMessages += provider.assistantMessages;
|
|
456
|
+
if (provider.cacheBuckets) {
|
|
457
|
+
const providerBuckets = (existing.cacheBuckets ||= emptyCacheUsageBuckets());
|
|
458
|
+
mergeCacheUsageBucket(providerBuckets.readOnly, provider.cacheBuckets.readOnly);
|
|
459
|
+
mergeCacheUsageBucket(providerBuckets.readWrite, provider.cacheBuckets.readWrite);
|
|
460
|
+
}
|
|
448
461
|
target.providers[provider.providerID] = existing;
|
|
449
462
|
}
|
|
450
463
|
return target;
|
|
@@ -462,6 +475,7 @@ export function toCachedSessionUsage(summary) {
|
|
|
462
475
|
cost: provider.cost,
|
|
463
476
|
apiCost: provider.apiCost,
|
|
464
477
|
assistantMessages: provider.assistantMessages,
|
|
478
|
+
cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
|
|
465
479
|
};
|
|
466
480
|
return acc;
|
|
467
481
|
}, {});
|
|
@@ -509,6 +523,7 @@ export function fromCachedSessionUsage(cached, sessionCount = 1) {
|
|
|
509
523
|
cost: provider.cost,
|
|
510
524
|
apiCost: provider.apiCost || 0,
|
|
511
525
|
assistantMessages: provider.assistantMessages,
|
|
526
|
+
cacheBuckets: cloneCacheUsageBuckets(provider.cacheBuckets),
|
|
512
527
|
};
|
|
513
528
|
return acc;
|
|
514
529
|
}, {}),
|
package/dist/usage_service.js
CHANGED
|
@@ -210,6 +210,7 @@ export function createUsageService(deps) {
|
|
|
210
210
|
.filter((item) => Boolean(item));
|
|
211
211
|
if (decoded.length > 0 && decoded.length < value.length) {
|
|
212
212
|
debug(`message entries partially decoded: kept ${decoded.length}/${value.length}`);
|
|
213
|
+
return undefined;
|
|
213
214
|
}
|
|
214
215
|
// If the API returned entries but none match the expected shape,
|
|
215
216
|
// treat it as a load failure so we don't silently undercount.
|
|
@@ -260,7 +261,7 @@ export function createUsageService(deps) {
|
|
|
260
261
|
key.startsWith(`${canonicalProviderID}:`));
|
|
261
262
|
});
|
|
262
263
|
};
|
|
263
|
-
const summarizeSessionUsage = async (sessionID, generationAtStart) => {
|
|
264
|
+
const summarizeSessionUsage = async (sessionID, generationAtStart, options) => {
|
|
264
265
|
const entries = await loadSessionEntries(sessionID);
|
|
265
266
|
const sessionState = deps.state.sessions[sessionID];
|
|
266
267
|
// If we can't load messages (transient API failure), fall back to cached
|
|
@@ -272,6 +273,9 @@ export function createUsageService(deps) {
|
|
|
272
273
|
persist: false,
|
|
273
274
|
};
|
|
274
275
|
}
|
|
276
|
+
if (options?.requireEntries) {
|
|
277
|
+
throw new Error(`session usage unavailable: failed to load messages for ${sessionID}`);
|
|
278
|
+
}
|
|
275
279
|
const empty = emptyUsageSummary();
|
|
276
280
|
empty.sessionCount = 1;
|
|
277
281
|
return { usage: empty, persist: false };
|
|
@@ -301,7 +305,7 @@ export function createUsageService(deps) {
|
|
|
301
305
|
}
|
|
302
306
|
return { usage, persist: true };
|
|
303
307
|
};
|
|
304
|
-
const summarizeSessionUsageLocked = async (sessionID) => {
|
|
308
|
+
const summarizeSessionUsageLocked = async (sessionID, options) => {
|
|
305
309
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
306
310
|
const generationAtStart = dirtyGeneration.get(sessionID) || 0;
|
|
307
311
|
const existing = usageInFlight.get(sessionID);
|
|
@@ -311,13 +315,15 @@ export function createUsageService(deps) {
|
|
|
311
315
|
continue;
|
|
312
316
|
return result;
|
|
313
317
|
}
|
|
314
|
-
const promise = summarizeSessionUsage(sessionID, generationAtStart);
|
|
318
|
+
const promise = summarizeSessionUsage(sessionID, generationAtStart, options);
|
|
315
319
|
const entry = { generation: generationAtStart, promise };
|
|
316
|
-
promise
|
|
320
|
+
void promise
|
|
321
|
+
.finally(() => {
|
|
317
322
|
const current = usageInFlight.get(sessionID);
|
|
318
323
|
if (current?.promise === promise)
|
|
319
324
|
usageInFlight.delete(sessionID);
|
|
320
|
-
})
|
|
325
|
+
})
|
|
326
|
+
.catch(() => undefined);
|
|
321
327
|
usageInFlight.set(sessionID, entry);
|
|
322
328
|
const result = await promise;
|
|
323
329
|
if ((dirtyGeneration.get(sessionID) || 0) !== generationAtStart)
|
|
@@ -345,8 +351,11 @@ export function createUsageService(deps) {
|
|
|
345
351
|
maxSessions: deps.config.sidebar.childrenMaxSessions,
|
|
346
352
|
concurrency: deps.config.sidebar.childrenConcurrency,
|
|
347
353
|
});
|
|
348
|
-
if (descendantIDs.length === 0)
|
|
354
|
+
if (descendantIDs.length === 0) {
|
|
355
|
+
if (dirty)
|
|
356
|
+
deps.persistence.scheduleSave();
|
|
349
357
|
return usage;
|
|
358
|
+
}
|
|
350
359
|
const merged = emptyUsageSummary();
|
|
351
360
|
mergeUsage(merged, usage);
|
|
352
361
|
const needsFetch = [];
|
|
@@ -424,6 +433,7 @@ export function createUsageService(deps) {
|
|
|
424
433
|
dateKey: session.dateKey,
|
|
425
434
|
createdAt: session.state.createdAt,
|
|
426
435
|
lastMessageTime: session.state.cursor?.lastMessageTime,
|
|
436
|
+
dirty: session.state.dirty === true,
|
|
427
437
|
computed: emptyUsageSummary(),
|
|
428
438
|
fullUsage: undefined,
|
|
429
439
|
loadFailed: true,
|
|
@@ -442,6 +452,7 @@ export function createUsageService(deps) {
|
|
|
442
452
|
dateKey: session.dateKey,
|
|
443
453
|
createdAt: session.state.createdAt,
|
|
444
454
|
lastMessageTime: session.state.cursor?.lastMessageTime,
|
|
455
|
+
dirty: session.state.dirty === true,
|
|
445
456
|
computed,
|
|
446
457
|
fullUsage: undefined,
|
|
447
458
|
loadFailed: false,
|
|
@@ -458,6 +469,7 @@ export function createUsageService(deps) {
|
|
|
458
469
|
dateKey: session.dateKey,
|
|
459
470
|
createdAt: session.state.createdAt,
|
|
460
471
|
lastMessageTime: cursor.lastMessageTime,
|
|
472
|
+
dirty: false,
|
|
461
473
|
computed,
|
|
462
474
|
fullUsage,
|
|
463
475
|
loadFailed: false,
|
|
@@ -468,6 +480,8 @@ export function createUsageService(deps) {
|
|
|
468
480
|
const failedLoads = fetched.filter((item) => {
|
|
469
481
|
if (!item.loadFailed)
|
|
470
482
|
return false;
|
|
483
|
+
if (item.dirty)
|
|
484
|
+
return true;
|
|
471
485
|
const lastMessageTime = item.lastMessageTime;
|
|
472
486
|
if (typeof lastMessageTime === 'number' && lastMessageTime < startAt) {
|
|
473
487
|
return false;
|
|
@@ -492,6 +506,7 @@ export function createUsageService(deps) {
|
|
|
492
506
|
dateKeyFromTimestamp(memoryState.createdAt);
|
|
493
507
|
deps.state.sessionDateMap[sessionID] = resolvedDateKey;
|
|
494
508
|
deps.persistence.markDirty(resolvedDateKey);
|
|
509
|
+
memoryState.dirty = false;
|
|
495
510
|
dirty = true;
|
|
496
511
|
}
|
|
497
512
|
else if (persist && fullUsage) {
|
|
@@ -504,7 +519,13 @@ export function createUsageService(deps) {
|
|
|
504
519
|
}
|
|
505
520
|
}
|
|
506
521
|
if (diskOnlyUpdates.length > 0) {
|
|
507
|
-
await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch(
|
|
522
|
+
const persisted = await updateSessionsInDayChunks(deps.statePath, diskOnlyUpdates).catch((error) => {
|
|
523
|
+
swallow('updateSessionsInDayChunks')(error);
|
|
524
|
+
return false;
|
|
525
|
+
});
|
|
526
|
+
if (!persisted) {
|
|
527
|
+
throw new Error(`range usage unavailable: failed to persist ${diskOnlyUpdates.length} disk-only session(s)`);
|
|
528
|
+
}
|
|
508
529
|
}
|
|
509
530
|
if (dirty)
|
|
510
531
|
deps.persistence.scheduleSave();
|
|
@@ -513,6 +534,16 @@ export function createUsageService(deps) {
|
|
|
513
534
|
};
|
|
514
535
|
const summarizeForTool = async (period, sessionID, includeChildren) => {
|
|
515
536
|
if (period === 'session') {
|
|
537
|
+
if (!includeChildren) {
|
|
538
|
+
const session = await summarizeSessionUsageLocked(sessionID, {
|
|
539
|
+
requireEntries: true,
|
|
540
|
+
});
|
|
541
|
+
if (session.persist) {
|
|
542
|
+
persistSessionUsage(sessionID, toCachedSessionUsage(session.usage));
|
|
543
|
+
deps.persistence.scheduleSave();
|
|
544
|
+
}
|
|
545
|
+
return session.usage;
|
|
546
|
+
}
|
|
516
547
|
return summarizeSessionUsageForDisplay(sessionID, includeChildren);
|
|
517
548
|
}
|
|
518
549
|
return summarizeRangeUsage(period);
|