opencode-reviewer 0.5.1 → 0.6.1

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 (3) hide show
  1. package/README.md +19 -9
  2. package/dist/tui.js +178 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -92,6 +92,12 @@ The rating remains provisional until the full response is validated. A format re
92
92
  clears the preview and restores the loading indicator. Auto-approval
93
93
  starts only after the full response is validated and rendered.
94
94
 
95
+ Transient connection failures and HTTP 408/429/500/502/503/504 responses get up to
96
+ two internal retries within `timeoutMs`, honoring server cooldowns. A stream that
97
+ has already delivered assessment text is not restarted. This is separate from
98
+ `formatRetries`; no additional setting is needed. Retried requests can incur
99
+ additional provider charges, and unreported usage remains unknown.
100
+
95
101
  During an auto-approval countdown, click the countdown to allow once immediately,
96
102
  or Cancel to leave the request manual. Hiding or covering the panel also cancels
97
103
  that request's countdown. Cancel before using native Allow always or rejection
@@ -110,6 +116,12 @@ costs use reported charges; other endpoints use available catalog estimates.
110
116
  Received usage counts even if a review fails or is interrupted. Unreported charges
111
117
  are missing from the totals.
112
118
 
119
+ Lifetime statistics also show **Safe** and **Unsafe** review counts. Each completed,
120
+ validated review counts once, even without usage data. Previews, retries and failed
121
+ or interrupted analyses do not count. Re-enabling a conversation can produce a new
122
+ review, which counts again. The totals persist across restarts; earlier ratings
123
+ were not recorded and cannot be reconstructed.
124
+
113
125
  Reviews send the pending request, latest user prompt, project context, and relevant
114
126
  file snapshots, diffs, or tool arguments to your endpoint before approval. Shell
115
127
  file snapshots can follow symlinks outside the project. Missing evidence is noted
@@ -117,21 +129,19 @@ in the report; ratings are advice based on the supplied evidence.
117
129
 
118
130
  ### Desktop notifications
119
131
 
120
- Transient banners have an **Opencode** heading and a small status icon: green
121
- checkmark for approvals, orange exclamation mark for attention/countdowns, red X
132
+ Transient banners have an **Opencode (Session name)** heading and a small status icon: green
133
+ checkmark for approvals, orange exclamation mark for attention, red X
122
134
  for errors, and a neutral code mark for completed responses. GNOME controls the
123
- heading's font weight. The event
124
- message and conversation title appear beneath it, even while the terminal is focused.
125
- An explicit separator keeps them distinct on desktops that flatten banner text:
135
+ heading's font weight. The event message appears beneath it, even while the
136
+ terminal is focused:
126
137
 
127
138
  - **Session needs attention:** questions and manual permissions. Reviewed requests
128
139
  wait for a final validated assessment; failures, canceled automation, and a Safe
129
140
  review blocked from starting its countdown also notify. Unreviewed requests
130
141
  notify immediately, including when conversation review is disabled.
131
- - **Reviewer will approve permission in X s:** one announcement when a positive
132
- countdown starts, using the attention sound. Confirmed automatic approval then
133
- sends **Reviewer approved a permission** with the approval sound. Zero delay
134
- sends only the confirmed approval notification. Approval sounds are limited to
142
+ - **Reviewer approved a permission:** sent with the approval sound after confirmed
143
+ automatic approval, for both positive and zero delays. The countdown is silent.
144
+ Approval sounds are limited to
135
145
  one every two seconds; every eligible banner is retained for delivery.
136
146
  - **Session error:** an unrecovered session/provider failure, not a review failure.
137
147
  - **Session ended:** a completed root-agent response, not a question/permission
package/dist/tui.js CHANGED
@@ -1181,6 +1181,9 @@ var uiText = {
1181
1181
  tokenCoverage: (available, total) => `Token counts available: ${available}/${total} requests`,
1182
1182
  pricingCoverage: (available, total) => `Pricing available: ${available}/${total} requests`,
1183
1183
  since: (date) => `Recorded since: ${date}`,
1184
+ ratings: (safe, unsafe) => `${safe} \u2713 ${unsafe} \u2717`,
1185
+ ratingsSince: (date) => `Ratings recorded since: ${date}`,
1186
+ ratingsExplanation: "Ratings count completed, validated reviews, including those without usage data. Previews, retries and failed or interrupted analyses do not count. A fresh review after re-enabling counts again. Earlier ratings were not recorded and cannot be recovered.",
1184
1187
  explanation: "Received usage only, including interrupted or unsuccessful reviews. Unreported charges remain unknown. Costs combine endpoint-reported amounts and catalog estimates; legacy history retains its original estimates. Earlier unrecorded usage cannot be recovered."
1185
1188
  },
1186
1189
  notifications: {
@@ -1189,7 +1192,6 @@ var uiText = {
1189
1192
  openSession: "Open session",
1190
1193
  fallbackSession: "OpenCode session",
1191
1194
  attention: "Session needs attention",
1192
- countdown: (seconds) => `Reviewer will approve permission in ${seconds} s`,
1193
1195
  approved: "Reviewer approved a permission",
1194
1196
  error: "Session error",
1195
1197
  ended: "Session ended",
@@ -1603,6 +1605,74 @@ function measured(trace, phase, run) {
1603
1605
  return trace ? trace.measure(phase, run) : run();
1604
1606
  }
1605
1607
 
1608
+ // src/transport-retry.ts
1609
+ init_define_REVIEW_PROMPTS();
1610
+ init_define_REVIEW_SOUNDS();
1611
+ import { setTimeout as sleep } from "node:timers/promises";
1612
+ var statuses = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
1613
+ var networkCodes = /* @__PURE__ */ new Set([
1614
+ "EAI_AGAIN",
1615
+ "ECONNRESET",
1616
+ "ECONNREFUSED",
1617
+ "ECONNABORTED",
1618
+ "EPIPE",
1619
+ "ETIMEDOUT",
1620
+ "ENETUNREACH",
1621
+ "EHOSTUNREACH",
1622
+ "UND_ERR_CONNECT_TIMEOUT",
1623
+ "UND_ERR_HEADERS_TIMEOUT",
1624
+ "UND_ERR_BODY_TIMEOUT",
1625
+ "UND_ERR_SOCKET"
1626
+ ]);
1627
+ var TransientTransportError = class extends Error {
1628
+ constructor(message, notBefore = 0) {
1629
+ super(message);
1630
+ this.notBefore = notBefore;
1631
+ }
1632
+ notBefore;
1633
+ };
1634
+ function networkFailure(error, message) {
1635
+ for (let depth = 0; depth < 4 && error && typeof error === "object"; depth++) {
1636
+ const value = error;
1637
+ if (typeof value.code === "string") {
1638
+ return networkCodes.has(value.code) ? new TransientTransportError(message) : new Error(message);
1639
+ }
1640
+ error = value.cause;
1641
+ }
1642
+ return new Error(message);
1643
+ }
1644
+ function retryAfter(value, now = Date.now()) {
1645
+ if (value === null) return 0;
1646
+ value = value.trim();
1647
+ if (/^\d+$/.test(value)) return Number(value) * 1e3;
1648
+ if (!/^(?:[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT|[A-Za-z]+, \d{2}-[A-Za-z]{3}-\d{2} \d{2}:\d{2}:\d{2} GMT|[A-Za-z]{3} [A-Za-z]{3} [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/.test(value)) return 0;
1649
+ const date = Date.parse(value.endsWith("GMT") ? value : `${value} GMT`);
1650
+ return Number.isFinite(date) ? Math.max(0, date - now) : 0;
1651
+ }
1652
+ function httpFailure(response) {
1653
+ const message = `Reviewer HTTP ${response.status}`;
1654
+ return statuses.has(response.status) ? new TransientTransportError(message, performance.now() + retryAfter(response.headers.get("retry-after"))) : new Error(message);
1655
+ }
1656
+ var TransportRetries = class {
1657
+ used = 0;
1658
+ async wait(error, signal) {
1659
+ reviewStage(signal, "Reviewer response");
1660
+ if (!(error instanceof TransientTransportError) || this.used >= 2) return false;
1661
+ const base = 250 * 2 ** this.used;
1662
+ const delay = Math.max(base + Math.random() * base, error.notBefore - performance.now());
1663
+ if (delay + 250 >= remainingTime(signal)) return false;
1664
+ this.used++;
1665
+ try {
1666
+ await sleep(Math.ceil(delay), void 0, { signal });
1667
+ } catch {
1668
+ signal.throwIfAborted();
1669
+ throw error;
1670
+ }
1671
+ reviewStage(signal, "Reviewer response");
1672
+ return remainingTime(signal) > 250;
1673
+ }
1674
+ };
1675
+
1606
1676
  // src/reviewer.ts
1607
1677
  function abortable(promise, signal, late) {
1608
1678
  return new Promise((resolve, reject) => {
@@ -1647,9 +1717,9 @@ async function readBody(response, signal, consume) {
1647
1717
  let part;
1648
1718
  try {
1649
1719
  part = await abortable(reader2.read(), signal);
1650
- } catch {
1720
+ } catch (error) {
1651
1721
  signal.throwIfAborted();
1652
- throw new Error("Reviewer response read failed");
1722
+ throw networkFailure(error, "Reviewer response read failed");
1653
1723
  }
1654
1724
  signal.throwIfAborted();
1655
1725
  if (part.done) break;
@@ -1748,6 +1818,15 @@ async function streamedAssessment(response, signal, accounting, progress, firstC
1748
1818
  return assessment;
1749
1819
  }
1750
1820
  async function review(evidence, config, signal, fetcher = fetch, environment = process.env, prompts = BUILTIN_PROMPTS, pricing, onUsage, onProgress, onDiagnostics) {
1821
+ if (remainingTime(signal) === Infinity) {
1822
+ let worker;
1823
+ try {
1824
+ return await withDeadline(signal, config.timeoutMs, (bounded) => worker = review(evidence, config, bounded, fetcher, environment, prompts, pricing, onUsage, onProgress, onDiagnostics));
1825
+ } finally {
1826
+ await worker?.catch(() => {
1827
+ });
1828
+ }
1829
+ }
1751
1830
  const key = config.apiKey ?? (config.apiKeyEnv ? environment[config.apiKeyEnv]?.trim() : void 0);
1752
1831
  if (config.apiKeyEnv && !key) throw new Error(`API key environment variable ${config.apiKeyEnv} is unset or empty`);
1753
1832
  const prompt2 = prompts[evidence.kind];
@@ -1756,7 +1835,9 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1756
1835
  { role: "user", content: JSON.stringify(evidence) }
1757
1836
  ];
1758
1837
  let usage;
1759
- for (let attempt = 0; attempt <= config.formatRetries; attempt++) {
1838
+ let corrections = 0;
1839
+ const retries = new TransportRetries();
1840
+ for (let attempt = 0; ; attempt++) {
1760
1841
  signal.throwIfAborted();
1761
1842
  const progress = (phase, preview) => {
1762
1843
  if (signal.aborted) return;
@@ -1768,6 +1849,10 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1768
1849
  };
1769
1850
  progress(attempt ? "retrying" : "evaluating");
1770
1851
  const accounting = usageAttempt(config.baseURL, config.model, pricing, onUsage);
1852
+ const requestAbort = new AbortController();
1853
+ let contentStarted = false;
1854
+ let failed = false;
1855
+ let failure;
1771
1856
  try {
1772
1857
  reviewStage(signal, "Reviewer response");
1773
1858
  let response;
@@ -1776,31 +1861,36 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1776
1861
  const body = JSON.stringify({
1777
1862
  model: config.model,
1778
1863
  messages,
1864
+ max_tokens: 2e3,
1779
1865
  stream: config.stream,
1780
1866
  ...config.stream ? { stream_options: { include_usage: true } } : {}
1781
1867
  });
1868
+ reviewStage(signal, "Reviewer response");
1782
1869
  diagnose = onDiagnostics ? diagnosticAttempt(onDiagnostics, attempt) : void 0;
1783
1870
  response = await abortable(fetcher(`${config.baseURL}/chat/completions`, {
1784
1871
  method: "POST",
1785
1872
  redirect: "error",
1786
- signal,
1873
+ signal: AbortSignal.any([signal, requestAbort.signal]),
1787
1874
  headers: { "Content-Type": "application/json", ...key ? { Authorization: `Bearer ${key}` } : {} },
1788
1875
  body
1789
1876
  }), signal, cancelBody);
1790
1877
  diagnose?.("headers");
1791
- } catch {
1878
+ } catch (error) {
1792
1879
  signal.throwIfAborted();
1793
- throw new Error("Reviewer network request failed");
1880
+ throw networkFailure(error, "Reviewer network request failed");
1794
1881
  }
1795
1882
  if (!response.ok) {
1796
1883
  cancelBody(response);
1797
- throw new Error(`Reviewer HTTP ${response.status}`);
1884
+ throw httpFailure(response);
1798
1885
  }
1799
1886
  let assessment;
1800
1887
  if (config.stream) assessment = await streamedAssessment(response, signal, accounting, (preview) => {
1801
1888
  if (typeof preview?.safe === "boolean") diagnose?.("first-rating");
1802
1889
  progress("streaming", preview);
1803
- }, diagnose ? () => diagnose("first-content") : void 0);
1890
+ }, () => {
1891
+ contentStarted = true;
1892
+ diagnose?.("first-content");
1893
+ });
1804
1894
  else {
1805
1895
  let envelope;
1806
1896
  try {
@@ -1818,14 +1908,17 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1818
1908
  if (!object(choice) || !object(message) || typeof message.content !== "string" || !textMetadata(message) || object(envelope) && envelope.error != null || choice.error != null || choice.index !== void 0 && choice.index !== 0 || choice.finish_reason !== void 0 && choice.finish_reason !== "stop") {
1819
1909
  throw new Error("Reviewer API did not return a text assessment");
1820
1910
  }
1821
- if (message.content) diagnose?.("first-content");
1911
+ if (message.content) {
1912
+ contentStarted = true;
1913
+ diagnose?.("first-content");
1914
+ }
1822
1915
  assessment = new StreamingAssessment();
1823
1916
  assessment.push(message.content);
1824
1917
  if (diagnose && typeof assessment.preview()?.safe === "boolean") diagnose("first-rating");
1825
1918
  }
1826
1919
  reviewStage(signal, "Assessment validation");
1827
1920
  const currentUsage = accounting.current();
1828
- usage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
1921
+ const reportUsage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
1829
1922
  try {
1830
1923
  let result;
1831
1924
  try {
@@ -1835,20 +1928,26 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1835
1928
  }
1836
1929
  if (config.stream) progress("streaming", result);
1837
1930
  signal.throwIfAborted();
1838
- return { ...result, ...usage ? { usage } : {} };
1931
+ return { ...result, ...reportUsage ? { usage: reportUsage } : {} };
1839
1932
  } catch (error) {
1840
1933
  if (!(error instanceof AssessmentFormatError)) throw error;
1841
- if (attempt === config.formatRetries) throw new Error("Reviewer response format invalid after configured attempts");
1934
+ if (corrections++ === config.formatRetries) throw new Error("Reviewer response format invalid after configured attempts");
1842
1935
  messages.push(
1843
1936
  { role: "assistant", content: assessment.content },
1844
1937
  { role: "user", content: correctionPrompt(error.message) }
1845
1938
  );
1846
1939
  }
1940
+ } catch (error) {
1941
+ requestAbort.abort();
1942
+ if (contentStarted) throw error;
1943
+ failed = true;
1944
+ failure = error;
1847
1945
  } finally {
1848
- accounting.finalize();
1946
+ const currentUsage = accounting.finalize();
1947
+ usage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
1849
1948
  }
1949
+ if (failed && !await retries.wait(failure, signal)) throw failure;
1850
1950
  }
1851
- throw new Error("Reviewer exhausted attempts");
1852
1951
  }
1853
1952
 
1854
1953
  // src/classification.ts
@@ -1980,7 +2079,7 @@ var clock = {
1980
2079
  };
1981
2080
  var permissionResolved = Object.freeze({ reason: "permission resolved" });
1982
2081
  var Controller = class {
1983
- constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver) {
2082
+ constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver, ratingObserver) {
1984
2083
  this.evaluate = evaluate;
1985
2084
  this.changed = changed;
1986
2085
  this.options = options;
@@ -1988,6 +2087,7 @@ var Controller = class {
1988
2087
  this.time = time;
1989
2088
  this.modes = modes;
1990
2089
  this.approvalObserver = approvalObserver;
2090
+ this.ratingObserver = ratingObserver;
1991
2091
  }
1992
2092
  evaluate;
1993
2093
  changed;
@@ -1996,6 +2096,7 @@ var Controller = class {
1996
2096
  time;
1997
2097
  modes;
1998
2098
  approvalObserver;
2099
+ ratingObserver;
1999
2100
  entries = /* @__PURE__ */ new Map();
2000
2101
  stopped = false;
2001
2102
  version = 0;
@@ -2288,6 +2389,13 @@ var Controller = class {
2288
2389
  this.clearProgress(entry);
2289
2390
  if (active()) {
2290
2391
  entry.view = assessment ? { ...entry.view, status: "complete", assessment, progress: void 0 } : { ...entry.view, status: "unrelated", progress: void 0 };
2392
+ if (assessment) {
2393
+ try {
2394
+ void Promise.resolve(this.ratingObserver?.(assessment.safe)).catch(() => {
2395
+ });
2396
+ } catch {
2397
+ }
2398
+ }
2291
2399
  this.publish();
2292
2400
  }
2293
2401
  }, (error) => {
@@ -3538,12 +3646,25 @@ import { constants as constants3 } from "node:fs";
3538
3646
  import { mkdir, open as open3, readdir as readdir2, rename, unlink } from "node:fs/promises";
3539
3647
  import { randomUUID } from "node:crypto";
3540
3648
  import path8 from "node:path";
3541
- var empty = () => ({ requests: 0, tokenRequests: 0, input: 0, output: 0, priced: 0, cost: 0, since: null });
3649
+ var empty = () => ({
3650
+ requests: 0,
3651
+ tokenRequests: 0,
3652
+ input: 0,
3653
+ output: 0,
3654
+ priced: 0,
3655
+ cost: 0,
3656
+ since: null,
3657
+ safe: 0,
3658
+ unsafe: 0,
3659
+ ratingsSince: null
3660
+ });
3542
3661
  var count2 = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3543
3662
  var amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
3544
3663
  var snapshotName = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/;
3545
3664
  function validate(value) {
3546
- if (!value || ![value.requests, value.tokenRequests, value.input, value.output, value.priced].every(count2) || !amount(value.cost) || value.priced > value.requests || value.tokenRequests > value.requests || value.requests > value.tokenRequests + value.priced || !value.tokenRequests && (value.input !== 0 || value.output !== 0) || (value.requests > 0 ? !count2(value.since) || value.since > 864e13 : value.since !== null || value.input !== 0 || value.output !== 0) || !value.priced && value.cost !== 0) throw new Error("Invalid lifetime usage totals");
3665
+ if (!value || ![value.requests, value.tokenRequests, value.input, value.output, value.priced, value.safe, value.unsafe, value.safe + value.unsafe].every(count2) || !amount(value.cost) || value.priced > value.requests || value.tokenRequests > value.requests || value.requests > value.tokenRequests + value.priced || !value.tokenRequests && (value.input !== 0 || value.output !== 0) || (value.requests > 0 ? !count2(value.since) || value.since > 864e13 : value.since !== null || value.input !== 0 || value.output !== 0) || !value.priced && value.cost !== 0 || (value.safe + value.unsafe > 0 ? !count2(value.ratingsSince) || value.ratingsSince > 864e13 : value.ratingsSince !== null)) {
3666
+ throw new Error("Invalid lifetime usage totals");
3667
+ }
3547
3668
  return value;
3548
3669
  }
3549
3670
  function add(left, right) {
@@ -3554,28 +3675,32 @@ function add(left, right) {
3554
3675
  tokenRequests: left.tokenRequests + right.tokenRequests,
3555
3676
  priced: left.priced + right.priced,
3556
3677
  cost: left.cost + right.cost,
3557
- since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since)
3678
+ since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since),
3679
+ safe: left.safe + right.safe,
3680
+ unsafe: left.unsafe + right.unsafe,
3681
+ ratingsSince: left.ratingsSince === null ? right.ratingsSince : right.ratingsSince === null ? left.ratingsSince : Math.min(left.ratingsSince, right.ratingsSince)
3558
3682
  });
3559
3683
  }
3560
3684
  var LifetimeUsage = class {
3561
- constructor(directory, legacyDirectory) {
3685
+ constructor(directory, ...legacyDirectories) {
3562
3686
  this.directory = directory;
3563
- this.legacyDirectory = legacyDirectory;
3564
- if (!path8.isAbsolute(directory) || legacyDirectory !== void 0 && !path8.isAbsolute(legacyDirectory)) {
3687
+ if (![directory, ...legacyDirectories].every((value) => path8.isAbsolute(value))) {
3565
3688
  throw new Error("Lifetime usage directory must be absolute");
3566
3689
  }
3690
+ this.legacyDirectories = legacyDirectories;
3567
3691
  }
3568
3692
  directory;
3569
- legacyDirectory;
3570
3693
  id = randomUUID();
3571
3694
  local = empty();
3572
3695
  pending = Promise.resolve();
3696
+ legacyDirectories;
3573
3697
  record(usage) {
3574
3698
  const tokens = count2(usage.input) && count2(usage.output);
3575
3699
  if (!tokens && (usage.input !== void 0 || usage.output !== void 0 || usage.cost === void 0) || usage.cost !== void 0 && !amount(usage.cost)) {
3576
3700
  return Promise.reject(new Error("Invalid request usage"));
3577
3701
  }
3578
- const increment = {
3702
+ return this.increment({
3703
+ ...empty(),
3579
3704
  requests: 1,
3580
3705
  tokenRequests: tokens ? 1 : 0,
3581
3706
  input: usage.input ?? 0,
@@ -3583,7 +3708,14 @@ var LifetimeUsage = class {
3583
3708
  priced: usage.cost === void 0 ? 0 : 1,
3584
3709
  cost: usage.cost ?? 0,
3585
3710
  since: Date.now()
3586
- };
3711
+ });
3712
+ }
3713
+ /** One accepted final review, independent of POST count or received usage. */
3714
+ recordRating(safe) {
3715
+ if (typeof safe !== "boolean") return Promise.reject(new Error("Invalid review rating"));
3716
+ return this.increment({ ...empty(), safe: safe ? 1 : 0, unsafe: safe ? 0 : 1, ratingsSince: Date.now() });
3717
+ }
3718
+ increment(increment) {
3587
3719
  this.pending = this.pending.catch(() => {
3588
3720
  }).then(async () => {
3589
3721
  this.local = add(this.local, increment);
@@ -3592,7 +3724,7 @@ var LifetimeUsage = class {
3592
3724
  try {
3593
3725
  const file = await open3(temporary, "wx", 384);
3594
3726
  try {
3595
- await file.writeFile(JSON.stringify({ version: 2, ...this.local }));
3727
+ await file.writeFile(JSON.stringify({ version: 3, ...this.local }));
3596
3728
  await file.sync();
3597
3729
  } finally {
3598
3730
  await file.close();
@@ -3620,7 +3752,7 @@ var LifetimeUsage = class {
3620
3752
  await this.flush();
3621
3753
  signal?.throwIfAborted();
3622
3754
  let total = empty();
3623
- const directories = new Set([this.directory, ...this.legacyDirectory ? [this.legacyDirectory] : []].map((directory) => path8.resolve(directory)));
3755
+ const directories = new Set([this.directory, ...this.legacyDirectories].map((directory) => path8.resolve(directory)));
3624
3756
  for (const directory of directories) total = add(total, await this.readDirectory(directory, signal));
3625
3757
  signal?.throwIfAborted();
3626
3758
  return total;
@@ -3652,7 +3784,7 @@ var LifetimeUsage = class {
3652
3784
  }
3653
3785
  if (size > 1024) throw new Error("Invalid lifetime usage snapshot");
3654
3786
  const value = JSON.parse(bytes.subarray(0, size).toString("utf8"));
3655
- if (value?.version !== 1 && value?.version !== 2) throw new Error("Unsupported lifetime usage snapshot");
3787
+ if (value?.version !== 1 && value?.version !== 2 && value?.version !== 3) throw new Error("Unsupported lifetime usage snapshot");
3656
3788
  total = add(total, validate({
3657
3789
  requests: value.requests,
3658
3790
  input: value.input,
@@ -3660,7 +3792,10 @@ var LifetimeUsage = class {
3660
3792
  tokenRequests: value.version === 1 ? value.requests : value.tokenRequests,
3661
3793
  priced: value.priced,
3662
3794
  cost: value.cost,
3663
- since: value.since
3795
+ since: value.since,
3796
+ safe: value.version === 3 ? value.safe : 0,
3797
+ unsafe: value.version === 3 ? value.unsafe : 0,
3798
+ ratingsSince: value.version === 3 ? value.ratingsSince : null
3664
3799
  }));
3665
3800
  } finally {
3666
3801
  await file.close();
@@ -3679,13 +3814,17 @@ function lifetimeCost(totals) {
3679
3814
  function lifetimeReport(totals) {
3680
3815
  return [
3681
3816
  lifetimeCost(totals),
3817
+ uiText.lifetime.ratings(totals.safe, totals.unsafe),
3682
3818
  uiText.lifetime.requests(totals.requests),
3683
3819
  totals.tokenRequests ? uiText.lifetime.tokens(totals.input, totals.output, totals.tokenRequests < totals.requests) : uiText.lifetime.tokensUnavailable,
3684
3820
  uiText.lifetime.tokenCoverage(totals.tokenRequests, totals.requests),
3685
3821
  uiText.lifetime.pricingCoverage(totals.priced, totals.requests),
3686
3822
  ...totals.since === null ? [] : [uiText.lifetime.since(new Date(totals.since).toISOString().slice(0, 10))],
3823
+ ...totals.ratingsSince === null ? [] : [uiText.lifetime.ratingsSince(new Date(totals.ratingsSince).toISOString().slice(0, 10))],
3824
+ "",
3825
+ uiText.lifetime.explanation,
3687
3826
  "",
3688
- uiText.lifetime.explanation
3827
+ uiText.lifetime.ratingsExplanation
3689
3828
  ].join("\n");
3690
3829
  }
3691
3830
 
@@ -3771,7 +3910,7 @@ var LifetimeRefresh = class {
3771
3910
  // src/lifetime-view.tsx
3772
3911
  function lifetimeTracker(api) {
3773
3912
  const directory = path9.join(api.state.path.state, "opencode-reviewer");
3774
- const store = new LifetimeUsage(path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
3913
+ const store = new LifetimeUsage(path9.join(directory, "usage-v3"), path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
3775
3914
  const [totals, setTotals] = createSignal();
3776
3915
  const [unavailable, setUnavailable] = createSignal(false);
3777
3916
  const refresh = new LifetimeRefresh((signal) => store.totals(signal), (value) => {
@@ -3808,9 +3947,12 @@ function lifetimeTracker(api) {
3808
3947
  // The controller must settle aborted review workers before this final queue drain.
3809
3948
  flush: () => store.flush().catch(() => {
3810
3949
  }),
3811
- text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? lifetimeCost(totals()) : void 0,
3950
+ text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? [lifetimeCost(totals()), uiText.lifetime.ratings(totals().safe, totals().unsafe)].join("\n") : void 0,
3812
3951
  record: (usage) => {
3813
3952
  void store.record(usage).then(() => refresh.refresh(), () => refresh.failed());
3953
+ },
3954
+ recordRating: (safe) => {
3955
+ void store.recordRating(safe).then(() => refresh.refresh(), () => refresh.failed());
3814
3956
  }
3815
3957
  };
3816
3958
  }
@@ -4066,16 +4208,14 @@ function notificationMarkup(value) {
4066
4208
 
4067
4209
  // src/notification-policy.ts
4068
4210
  var NotificationPolicy = class {
4069
- constructor(config, auto, delay, backend, clock2 = notificationClock) {
4211
+ constructor(config, auto, backend, clock2 = notificationClock) {
4070
4212
  this.config = config;
4071
4213
  this.auto = auto;
4072
- this.delay = delay;
4073
4214
  this.backend = backend;
4074
4215
  this.clock = clock2;
4075
4216
  }
4076
4217
  config;
4077
4218
  auto;
4078
- delay;
4079
4219
  backend;
4080
4220
  clock;
4081
4221
  permissions = /* @__PURE__ */ new Map();
@@ -4176,7 +4316,7 @@ var NotificationPolicy = class {
4176
4316
  if (entry.phase !== "automatic") {
4177
4317
  this.withdraw(entry.banner);
4178
4318
  entry.phase = "automatic";
4179
- entry.banner = state === "countdown" && this.delay > 0 ? this.show("attention", uiText.notifications.countdown(view.autoApproval.status === "countdown" ? view.autoApproval.seconds : this.delay), entry.target) : void 0;
4319
+ entry.banner = void 0;
4180
4320
  }
4181
4321
  return;
4182
4322
  }
@@ -6188,7 +6328,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
6188
6328
  try {
6189
6329
  const click = (id) => notifications?.click(id);
6190
6330
  const backend = notificationBackend ? notificationBackend(click, notificationConfig) : new LinuxNotifications(notificationConfig, click);
6191
- notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true, reviewOptions?.autoApproveDelaySeconds ?? 15, backend), (id, signal) => modes.root(id, signal));
6331
+ notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true, backend), (id, signal) => modes.root(id, signal));
6192
6332
  } catch {
6193
6333
  }
6194
6334
  }
@@ -6229,7 +6369,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
6229
6369
  once: (request, signal) => measured(traces?.get(request), "approval-reply", () => approval.once(request, signal))
6230
6370
  } : approval,
6231
6371
  visibleID: () => visibleApproval()
6232
- }, void 0, modes, (fact) => notifications?.fact(fact));
6372
+ }, void 0, modes, (fact) => notifications?.fact(fact), lifetime.recordRating);
6233
6373
  api.event.on("permission.asked", (event) => controller.asked(event.properties));
6234
6374
  api.event.on("permission.replied", (event) => controller.replied(event.properties.requestID));
6235
6375
  api.event.on("session.deleted", (event) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-reviewer",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",