opencode-reviewer 0.5.1 → 0.6.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 (3) hide show
  1. package/README.md +19 -9
  2. package/dist/tui.js +177 -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: ${safe} \xB7 Unsafe: ${unsafe}`,
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;
@@ -1779,28 +1864,32 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1779
1864
  stream: config.stream,
1780
1865
  ...config.stream ? { stream_options: { include_usage: true } } : {}
1781
1866
  });
1867
+ reviewStage(signal, "Reviewer response");
1782
1868
  diagnose = onDiagnostics ? diagnosticAttempt(onDiagnostics, attempt) : void 0;
1783
1869
  response = await abortable(fetcher(`${config.baseURL}/chat/completions`, {
1784
1870
  method: "POST",
1785
1871
  redirect: "error",
1786
- signal,
1872
+ signal: AbortSignal.any([signal, requestAbort.signal]),
1787
1873
  headers: { "Content-Type": "application/json", ...key ? { Authorization: `Bearer ${key}` } : {} },
1788
1874
  body
1789
1875
  }), signal, cancelBody);
1790
1876
  diagnose?.("headers");
1791
- } catch {
1877
+ } catch (error) {
1792
1878
  signal.throwIfAborted();
1793
- throw new Error("Reviewer network request failed");
1879
+ throw networkFailure(error, "Reviewer network request failed");
1794
1880
  }
1795
1881
  if (!response.ok) {
1796
1882
  cancelBody(response);
1797
- throw new Error(`Reviewer HTTP ${response.status}`);
1883
+ throw httpFailure(response);
1798
1884
  }
1799
1885
  let assessment;
1800
1886
  if (config.stream) assessment = await streamedAssessment(response, signal, accounting, (preview) => {
1801
1887
  if (typeof preview?.safe === "boolean") diagnose?.("first-rating");
1802
1888
  progress("streaming", preview);
1803
- }, diagnose ? () => diagnose("first-content") : void 0);
1889
+ }, () => {
1890
+ contentStarted = true;
1891
+ diagnose?.("first-content");
1892
+ });
1804
1893
  else {
1805
1894
  let envelope;
1806
1895
  try {
@@ -1818,14 +1907,17 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1818
1907
  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
1908
  throw new Error("Reviewer API did not return a text assessment");
1820
1909
  }
1821
- if (message.content) diagnose?.("first-content");
1910
+ if (message.content) {
1911
+ contentStarted = true;
1912
+ diagnose?.("first-content");
1913
+ }
1822
1914
  assessment = new StreamingAssessment();
1823
1915
  assessment.push(message.content);
1824
1916
  if (diagnose && typeof assessment.preview()?.safe === "boolean") diagnose("first-rating");
1825
1917
  }
1826
1918
  reviewStage(signal, "Assessment validation");
1827
1919
  const currentUsage = accounting.current();
1828
- usage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
1920
+ const reportUsage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
1829
1921
  try {
1830
1922
  let result;
1831
1923
  try {
@@ -1835,20 +1927,26 @@ async function review(evidence, config, signal, fetcher = fetch, environment = p
1835
1927
  }
1836
1928
  if (config.stream) progress("streaming", result);
1837
1929
  signal.throwIfAborted();
1838
- return { ...result, ...usage ? { usage } : {} };
1930
+ return { ...result, ...reportUsage ? { usage: reportUsage } : {} };
1839
1931
  } catch (error) {
1840
1932
  if (!(error instanceof AssessmentFormatError)) throw error;
1841
- if (attempt === config.formatRetries) throw new Error("Reviewer response format invalid after configured attempts");
1933
+ if (corrections++ === config.formatRetries) throw new Error("Reviewer response format invalid after configured attempts");
1842
1934
  messages.push(
1843
1935
  { role: "assistant", content: assessment.content },
1844
1936
  { role: "user", content: correctionPrompt(error.message) }
1845
1937
  );
1846
1938
  }
1939
+ } catch (error) {
1940
+ requestAbort.abort();
1941
+ if (contentStarted) throw error;
1942
+ failed = true;
1943
+ failure = error;
1847
1944
  } finally {
1848
- accounting.finalize();
1945
+ const currentUsage = accounting.finalize();
1946
+ usage = attempt === 0 ? currentUsage : sumUsage(usage, currentUsage);
1849
1947
  }
1948
+ if (failed && !await retries.wait(failure, signal)) throw failure;
1850
1949
  }
1851
- throw new Error("Reviewer exhausted attempts");
1852
1950
  }
1853
1951
 
1854
1952
  // src/classification.ts
@@ -1980,7 +2078,7 @@ var clock = {
1980
2078
  };
1981
2079
  var permissionResolved = Object.freeze({ reason: "permission resolved" });
1982
2080
  var Controller = class {
1983
- constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver) {
2081
+ constructor(evaluate, changed, options = { reviewBash: true, reviewEdits: true }, approval, time = clock, modes, approvalObserver, ratingObserver) {
1984
2082
  this.evaluate = evaluate;
1985
2083
  this.changed = changed;
1986
2084
  this.options = options;
@@ -1988,6 +2086,7 @@ var Controller = class {
1988
2086
  this.time = time;
1989
2087
  this.modes = modes;
1990
2088
  this.approvalObserver = approvalObserver;
2089
+ this.ratingObserver = ratingObserver;
1991
2090
  }
1992
2091
  evaluate;
1993
2092
  changed;
@@ -1996,6 +2095,7 @@ var Controller = class {
1996
2095
  time;
1997
2096
  modes;
1998
2097
  approvalObserver;
2098
+ ratingObserver;
1999
2099
  entries = /* @__PURE__ */ new Map();
2000
2100
  stopped = false;
2001
2101
  version = 0;
@@ -2288,6 +2388,13 @@ var Controller = class {
2288
2388
  this.clearProgress(entry);
2289
2389
  if (active()) {
2290
2390
  entry.view = assessment ? { ...entry.view, status: "complete", assessment, progress: void 0 } : { ...entry.view, status: "unrelated", progress: void 0 };
2391
+ if (assessment) {
2392
+ try {
2393
+ void Promise.resolve(this.ratingObserver?.(assessment.safe)).catch(() => {
2394
+ });
2395
+ } catch {
2396
+ }
2397
+ }
2291
2398
  this.publish();
2292
2399
  }
2293
2400
  }, (error) => {
@@ -3538,12 +3645,25 @@ import { constants as constants3 } from "node:fs";
3538
3645
  import { mkdir, open as open3, readdir as readdir2, rename, unlink } from "node:fs/promises";
3539
3646
  import { randomUUID } from "node:crypto";
3540
3647
  import path8 from "node:path";
3541
- var empty = () => ({ requests: 0, tokenRequests: 0, input: 0, output: 0, priced: 0, cost: 0, since: null });
3648
+ var empty = () => ({
3649
+ requests: 0,
3650
+ tokenRequests: 0,
3651
+ input: 0,
3652
+ output: 0,
3653
+ priced: 0,
3654
+ cost: 0,
3655
+ since: null,
3656
+ safe: 0,
3657
+ unsafe: 0,
3658
+ ratingsSince: null
3659
+ });
3542
3660
  var count2 = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3543
3661
  var amount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0;
3544
3662
  var snapshotName = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.json$/;
3545
3663
  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");
3664
+ 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)) {
3665
+ throw new Error("Invalid lifetime usage totals");
3666
+ }
3547
3667
  return value;
3548
3668
  }
3549
3669
  function add(left, right) {
@@ -3554,28 +3674,32 @@ function add(left, right) {
3554
3674
  tokenRequests: left.tokenRequests + right.tokenRequests,
3555
3675
  priced: left.priced + right.priced,
3556
3676
  cost: left.cost + right.cost,
3557
- since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since)
3677
+ since: left.since === null ? right.since : right.since === null ? left.since : Math.min(left.since, right.since),
3678
+ safe: left.safe + right.safe,
3679
+ unsafe: left.unsafe + right.unsafe,
3680
+ ratingsSince: left.ratingsSince === null ? right.ratingsSince : right.ratingsSince === null ? left.ratingsSince : Math.min(left.ratingsSince, right.ratingsSince)
3558
3681
  });
3559
3682
  }
3560
3683
  var LifetimeUsage = class {
3561
- constructor(directory, legacyDirectory) {
3684
+ constructor(directory, ...legacyDirectories) {
3562
3685
  this.directory = directory;
3563
- this.legacyDirectory = legacyDirectory;
3564
- if (!path8.isAbsolute(directory) || legacyDirectory !== void 0 && !path8.isAbsolute(legacyDirectory)) {
3686
+ if (![directory, ...legacyDirectories].every((value) => path8.isAbsolute(value))) {
3565
3687
  throw new Error("Lifetime usage directory must be absolute");
3566
3688
  }
3689
+ this.legacyDirectories = legacyDirectories;
3567
3690
  }
3568
3691
  directory;
3569
- legacyDirectory;
3570
3692
  id = randomUUID();
3571
3693
  local = empty();
3572
3694
  pending = Promise.resolve();
3695
+ legacyDirectories;
3573
3696
  record(usage) {
3574
3697
  const tokens = count2(usage.input) && count2(usage.output);
3575
3698
  if (!tokens && (usage.input !== void 0 || usage.output !== void 0 || usage.cost === void 0) || usage.cost !== void 0 && !amount(usage.cost)) {
3576
3699
  return Promise.reject(new Error("Invalid request usage"));
3577
3700
  }
3578
- const increment = {
3701
+ return this.increment({
3702
+ ...empty(),
3579
3703
  requests: 1,
3580
3704
  tokenRequests: tokens ? 1 : 0,
3581
3705
  input: usage.input ?? 0,
@@ -3583,7 +3707,14 @@ var LifetimeUsage = class {
3583
3707
  priced: usage.cost === void 0 ? 0 : 1,
3584
3708
  cost: usage.cost ?? 0,
3585
3709
  since: Date.now()
3586
- };
3710
+ });
3711
+ }
3712
+ /** One accepted final review, independent of POST count or received usage. */
3713
+ recordRating(safe) {
3714
+ if (typeof safe !== "boolean") return Promise.reject(new Error("Invalid review rating"));
3715
+ return this.increment({ ...empty(), safe: safe ? 1 : 0, unsafe: safe ? 0 : 1, ratingsSince: Date.now() });
3716
+ }
3717
+ increment(increment) {
3587
3718
  this.pending = this.pending.catch(() => {
3588
3719
  }).then(async () => {
3589
3720
  this.local = add(this.local, increment);
@@ -3592,7 +3723,7 @@ var LifetimeUsage = class {
3592
3723
  try {
3593
3724
  const file = await open3(temporary, "wx", 384);
3594
3725
  try {
3595
- await file.writeFile(JSON.stringify({ version: 2, ...this.local }));
3726
+ await file.writeFile(JSON.stringify({ version: 3, ...this.local }));
3596
3727
  await file.sync();
3597
3728
  } finally {
3598
3729
  await file.close();
@@ -3620,7 +3751,7 @@ var LifetimeUsage = class {
3620
3751
  await this.flush();
3621
3752
  signal?.throwIfAborted();
3622
3753
  let total = empty();
3623
- const directories = new Set([this.directory, ...this.legacyDirectory ? [this.legacyDirectory] : []].map((directory) => path8.resolve(directory)));
3754
+ const directories = new Set([this.directory, ...this.legacyDirectories].map((directory) => path8.resolve(directory)));
3624
3755
  for (const directory of directories) total = add(total, await this.readDirectory(directory, signal));
3625
3756
  signal?.throwIfAborted();
3626
3757
  return total;
@@ -3652,7 +3783,7 @@ var LifetimeUsage = class {
3652
3783
  }
3653
3784
  if (size > 1024) throw new Error("Invalid lifetime usage snapshot");
3654
3785
  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");
3786
+ if (value?.version !== 1 && value?.version !== 2 && value?.version !== 3) throw new Error("Unsupported lifetime usage snapshot");
3656
3787
  total = add(total, validate({
3657
3788
  requests: value.requests,
3658
3789
  input: value.input,
@@ -3660,7 +3791,10 @@ var LifetimeUsage = class {
3660
3791
  tokenRequests: value.version === 1 ? value.requests : value.tokenRequests,
3661
3792
  priced: value.priced,
3662
3793
  cost: value.cost,
3663
- since: value.since
3794
+ since: value.since,
3795
+ safe: value.version === 3 ? value.safe : 0,
3796
+ unsafe: value.version === 3 ? value.unsafe : 0,
3797
+ ratingsSince: value.version === 3 ? value.ratingsSince : null
3664
3798
  }));
3665
3799
  } finally {
3666
3800
  await file.close();
@@ -3679,13 +3813,17 @@ function lifetimeCost(totals) {
3679
3813
  function lifetimeReport(totals) {
3680
3814
  return [
3681
3815
  lifetimeCost(totals),
3816
+ uiText.lifetime.ratings(totals.safe, totals.unsafe),
3682
3817
  uiText.lifetime.requests(totals.requests),
3683
3818
  totals.tokenRequests ? uiText.lifetime.tokens(totals.input, totals.output, totals.tokenRequests < totals.requests) : uiText.lifetime.tokensUnavailable,
3684
3819
  uiText.lifetime.tokenCoverage(totals.tokenRequests, totals.requests),
3685
3820
  uiText.lifetime.pricingCoverage(totals.priced, totals.requests),
3686
3821
  ...totals.since === null ? [] : [uiText.lifetime.since(new Date(totals.since).toISOString().slice(0, 10))],
3822
+ ...totals.ratingsSince === null ? [] : [uiText.lifetime.ratingsSince(new Date(totals.ratingsSince).toISOString().slice(0, 10))],
3823
+ "",
3824
+ uiText.lifetime.explanation,
3687
3825
  "",
3688
- uiText.lifetime.explanation
3826
+ uiText.lifetime.ratingsExplanation
3689
3827
  ].join("\n");
3690
3828
  }
3691
3829
 
@@ -3771,7 +3909,7 @@ var LifetimeRefresh = class {
3771
3909
  // src/lifetime-view.tsx
3772
3910
  function lifetimeTracker(api) {
3773
3911
  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"));
3912
+ const store = new LifetimeUsage(path9.join(directory, "usage-v3"), path9.join(directory, "usage-v2"), path9.join(directory, "usage-v1"));
3775
3913
  const [totals, setTotals] = createSignal();
3776
3914
  const [unavailable, setUnavailable] = createSignal(false);
3777
3915
  const refresh = new LifetimeRefresh((signal) => store.totals(signal), (value) => {
@@ -3808,9 +3946,12 @@ function lifetimeTracker(api) {
3808
3946
  // The controller must settle aborted review workers before this final queue drain.
3809
3947
  flush: () => store.flush().catch(() => {
3810
3948
  }),
3811
- text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? lifetimeCost(totals()) : void 0,
3949
+ text: () => unavailable() ? uiText.lifetime.inlineUnavailable : totals()?.requests ? [lifetimeCost(totals()), uiText.lifetime.ratings(totals().safe, totals().unsafe)].join("\n") : void 0,
3812
3950
  record: (usage) => {
3813
3951
  void store.record(usage).then(() => refresh.refresh(), () => refresh.failed());
3952
+ },
3953
+ recordRating: (safe) => {
3954
+ void store.recordRating(safe).then(() => refresh.refresh(), () => refresh.failed());
3814
3955
  }
3815
3956
  };
3816
3957
  }
@@ -4066,16 +4207,14 @@ function notificationMarkup(value) {
4066
4207
 
4067
4208
  // src/notification-policy.ts
4068
4209
  var NotificationPolicy = class {
4069
- constructor(config, auto, delay, backend, clock2 = notificationClock) {
4210
+ constructor(config, auto, backend, clock2 = notificationClock) {
4070
4211
  this.config = config;
4071
4212
  this.auto = auto;
4072
- this.delay = delay;
4073
4213
  this.backend = backend;
4074
4214
  this.clock = clock2;
4075
4215
  }
4076
4216
  config;
4077
4217
  auto;
4078
- delay;
4079
4218
  backend;
4080
4219
  clock;
4081
4220
  permissions = /* @__PURE__ */ new Map();
@@ -4176,7 +4315,7 @@ var NotificationPolicy = class {
4176
4315
  if (entry.phase !== "automatic") {
4177
4316
  this.withdraw(entry.banner);
4178
4317
  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;
4318
+ entry.banner = void 0;
4180
4319
  }
4181
4320
  return;
4182
4321
  }
@@ -6188,7 +6327,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
6188
6327
  try {
6189
6328
  const click = (id) => notifications?.click(id);
6190
6329
  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));
6330
+ notifications = new NotificationHost(api, new NotificationPolicy(notificationConfig, reviewOptions?.autoApprove === true, backend), (id, signal) => modes.root(id, signal));
6192
6331
  } catch {
6193
6332
  }
6194
6333
  }
@@ -6229,7 +6368,7 @@ async function reviewTui(api, options, fileIO, observer, notificationBackend) {
6229
6368
  once: (request, signal) => measured(traces?.get(request), "approval-reply", () => approval.once(request, signal))
6230
6369
  } : approval,
6231
6370
  visibleID: () => visibleApproval()
6232
- }, void 0, modes, (fact) => notifications?.fact(fact));
6371
+ }, void 0, modes, (fact) => notifications?.fact(fact), lifetime.recordRating);
6233
6372
  api.event.on("permission.asked", (event) => controller.asked(event.properties));
6234
6373
  api.event.on("permission.replied", (event) => controller.replied(event.properties.requestID));
6235
6374
  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.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public",