tau-all-agent 0.2.0 → 0.3.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 (42) hide show
  1. package/extensions/answer.ts +112 -85
  2. package/extensions/branch-term/index.ts +9 -5
  3. package/extensions/btw.ts +67 -36
  4. package/extensions/insights.ts +67 -56
  5. package/extensions/loop.ts +14 -52
  6. package/extensions/memory.ts +65 -33
  7. package/extensions/notify.ts +5 -3
  8. package/extensions/review/fix.ts +10 -16
  9. package/extensions/review/git.ts +7 -1
  10. package/extensions/review/index.ts +2 -2
  11. package/extensions/review/models.ts +12 -2
  12. package/extensions/review/review.ts +3 -5
  13. package/extensions/review/runtime.ts +9 -8
  14. package/extensions/review/triage.ts +2 -4
  15. package/extensions/sandbox/README.md +5 -0
  16. package/extensions/sandbox/bash.ts +83 -85
  17. package/extensions/sandbox/index.ts +21 -11
  18. package/extensions/sandbox/permissions/network.ts +8 -3
  19. package/extensions/sandbox/permissions/unsandboxed.ts +1 -240
  20. package/extensions/sandbox/runtime.ts +53 -19
  21. package/extensions/subagent/README.md +58 -1
  22. package/extensions/subagent/config.ts +118 -0
  23. package/extensions/subagent/index.ts +54 -22
  24. package/extensions/telegram/README.md +2 -0
  25. package/extensions/telegram/assistant-result.d.mts +4 -1
  26. package/extensions/telegram/assistant-result.mjs +31 -16
  27. package/extensions/telegram/daemon.mjs +146 -194
  28. package/extensions/telegram/index.ts +11 -7
  29. package/extensions/tool-display-mode.ts +29 -50
  30. package/extensions/usage/README.md +5 -0
  31. package/extensions/usage/index.ts +139 -80
  32. package/extensions/websearch/README.md +8 -1
  33. package/extensions/websearch/config.ts +2 -1
  34. package/extensions/websearch/index.ts +18 -9
  35. package/extensions/websearch/providers/{openai-codex.pi.ts → openai.pi.ts} +51 -16
  36. package/extensions/websearch/providers/shared.ts +10 -3
  37. package/extensions/websearch/types.ts +2 -1
  38. package/extensions/worktree.ts +33 -89
  39. package/package.json +1 -1
  40. package/skills/oracle/SKILL.md +14 -8
  41. package/skills/oracle/scripts/oracle +43 -19
  42. package/skills/update-changelog/SKILL.md +8 -4
@@ -0,0 +1,5 @@
1
+ # Usage
2
+
3
+ Run `/usage` for historical usage and supported providers' live quotas.
4
+
5
+ Live Codex quotas require `openai-codex` credentials, not `/login openai` credentials. History works without quota access.
@@ -8,9 +8,11 @@ import {
8
8
  Key,
9
9
  matchesKey,
10
10
  type Component,
11
+ type KeybindingsManager,
11
12
  type TUI,
12
13
  truncateToWidth,
13
14
  visibleWidth,
15
+ wrapTextWithAnsi,
14
16
  } from "@earendil-works/pi-tui";
15
17
  import { createHash } from "node:crypto";
16
18
  import { createReadStream, type Dirent } from "node:fs";
@@ -1599,15 +1601,11 @@ function formatUsageHeader(
1599
1601
  };
1600
1602
  }
1601
1603
 
1602
- function renderUsageHeader(
1603
- plan: string | undefined,
1604
- fetchedAt: Date | undefined,
1605
- width: number,
1606
- ): string {
1604
+ function renderUsageHeader(plan: string | undefined, fetchedAt: Date | undefined): string {
1607
1605
  const header = formatUsageHeader(plan, fetchedAt);
1608
1606
  let text = bold(header.title);
1609
1607
  if (header.fetched) text += ` ${dim(header.fetched)}`;
1610
- return truncateToWidth(text, width);
1608
+ return text;
1611
1609
  }
1612
1610
 
1613
1611
  type ResolvedLiveUsageState =
@@ -1669,31 +1667,25 @@ function renderLiveUsageLines(
1669
1667
 
1670
1668
  const resolved = resolveLiveUsageState(tab, liveState);
1671
1669
  if (resolved.kind === "loading") {
1672
- return [truncateToWidth(`${bold("Usage")} ${dim(resolved.message)}`, width)];
1670
+ return [`${bold("Usage")} ${dim(resolved.message)}`];
1673
1671
  }
1674
1672
 
1675
1673
  const lines: string[] = [
1676
1674
  renderUsageHeader(
1677
1675
  resolved.kind === "ok" ? resolved.snapshot.plan : undefined,
1678
1676
  resolved.kind === "ok" ? resolved.snapshot.fetchedAt : undefined,
1679
- width,
1680
1677
  ),
1681
1678
  ];
1682
1679
  if (resolved.kind === "message") {
1683
1680
  const message =
1684
1681
  resolved.tone === "error" ? ansiFg(ERROR_COLOR, resolved.message) : dim(resolved.message);
1685
- lines.push(truncateToWidth(message, width));
1682
+ lines.push(message);
1686
1683
  return lines;
1687
1684
  }
1688
1685
 
1689
1686
  for (const item of resolved.snapshot.items) {
1690
1687
  lines.push("");
1691
- lines.push(
1692
- truncateToWidth(
1693
- formatLiveUsageItemLine(item, { dimReset: true, emphasizeStatValue: true }),
1694
- width,
1695
- ),
1696
- );
1688
+ lines.push(formatLiveUsageItemLine(item, { dimReset: true, emphasizeStatValue: true }));
1697
1689
  if (item.kind === "meter" && tab.liveProvider) {
1698
1690
  lines.push(renderUsageMeterBar(tab.liveProvider.id, item, width));
1699
1691
  }
@@ -1752,12 +1744,17 @@ class UsageBreakdownComponent implements Component {
1752
1744
  private rangeIndex = 1;
1753
1745
  private measurement: MeasurementMode = "tokens";
1754
1746
  private view: BreakdownView = "model";
1747
+ private renderWidth?: number;
1755
1748
  private cachedWidth?: number;
1756
1749
  private cachedLines?: string[];
1750
+ private scrollOffset = 0;
1751
+ private viewportHeight = 0;
1752
+ private maxScroll = 0;
1757
1753
 
1758
1754
  constructor(
1759
1755
  data: BreakdownData,
1760
1756
  tui: TUI,
1757
+ private readonly keybindings: KeybindingsManager,
1761
1758
  onDone: () => void,
1762
1759
  loadLiveUsage: (
1763
1760
  provider: UsageProviderDefinition,
@@ -1771,55 +1768,6 @@ class UsageBreakdownComponent implements Component {
1771
1768
  this.ensureLiveUsageLoaded();
1772
1769
  }
1773
1770
 
1774
- private async close(): Promise<void> {
1775
- if (this.closing) return;
1776
- this.closing = true;
1777
- for (const controller of this.liveUsageControllers.values()) {
1778
- controller.abort();
1779
- }
1780
- await Promise.allSettled(this.liveUsageRequests.values());
1781
- this.onDone();
1782
- }
1783
-
1784
- private ensureLiveUsageLoaded(): void {
1785
- const tab = this.data.tabs[this.tabIndex];
1786
- const provider = tab?.liveProvider;
1787
- if (!provider) return;
1788
-
1789
- const current = this.data.liveUsage.get(provider.id);
1790
- if (current && current.status !== "loading") return;
1791
- if (this.liveUsageRequests.has(provider.id)) return;
1792
-
1793
- const controller = new AbortController();
1794
- this.liveUsageControllers.set(provider.id, controller);
1795
- this.data.liveUsage.set(provider.id, { status: "loading" });
1796
- this.invalidate();
1797
- this.tui.requestRender();
1798
-
1799
- const request = this.loadLiveUsage(provider, controller.signal)
1800
- .then((state) => {
1801
- if (controller.signal.aborted) return;
1802
- this.data.liveUsage.set(provider.id, state);
1803
- })
1804
- .catch((error) => {
1805
- if (controller.signal.aborted) return;
1806
- this.data.liveUsage.set(provider.id, {
1807
- status: "error",
1808
- error: error instanceof Error ? error.message : String(error),
1809
- });
1810
- })
1811
- .finally(() => {
1812
- this.liveUsageRequests.delete(provider.id);
1813
- this.liveUsageControllers.delete(provider.id);
1814
- if (!controller.signal.aborted) {
1815
- this.invalidate();
1816
- this.tui.requestRender();
1817
- }
1818
- });
1819
-
1820
- this.liveUsageRequests.set(provider.id, request);
1821
- }
1822
-
1823
1771
  invalidate(): void {
1824
1772
  this.cachedWidth = undefined;
1825
1773
  this.cachedLines = undefined;
@@ -1836,6 +1784,29 @@ class UsageBreakdownComponent implements Component {
1836
1784
  return;
1837
1785
  }
1838
1786
 
1787
+ const width = Math.min(
1788
+ this.renderWidth ?? this.tui.terminal.columns - 2,
1789
+ this.tui.terminal.columns - 2,
1790
+ );
1791
+ if (width < 24 || this.tui.terminal.rows < 7) return;
1792
+
1793
+ const scrollActions = [
1794
+ ["tui.altScreen.pageUp", -this.viewportHeight],
1795
+ ["tui.altScreen.pageDown", this.viewportHeight],
1796
+ ["tui.altScreen.halfPageUp", -Math.max(1, Math.floor(this.viewportHeight / 2))],
1797
+ ["tui.altScreen.halfPageDown", Math.max(1, Math.floor(this.viewportHeight / 2))],
1798
+ ["tui.altScreen.lineUp", -1],
1799
+ ["tui.altScreen.lineDown", 1],
1800
+ ["tui.altScreen.top", -Infinity],
1801
+ ["tui.altScreen.bottom", Infinity],
1802
+ ] as const;
1803
+ for (const [action, delta] of scrollActions) {
1804
+ if (!this.keybindings.matches(data, action)) continue;
1805
+ this.scrollOffset = Math.max(0, Math.min(this.maxScroll, this.scrollOffset + delta));
1806
+ this.tui.requestRender();
1807
+ return;
1808
+ }
1809
+
1839
1810
  if (
1840
1811
  matchesKey(data, Key.tab) ||
1841
1812
  matchesKey(data, Key.shift("tab")) ||
@@ -1845,6 +1816,7 @@ class UsageBreakdownComponent implements Component {
1845
1816
  const index = Math.max(0, order.indexOf(this.measurement));
1846
1817
  const direction = matchesKey(data, Key.shift("tab")) ? -1 : 1;
1847
1818
  this.measurement = order[(index + order.length + direction) % order.length] ?? "tokens";
1819
+ this.scrollOffset = 0;
1848
1820
  this.invalidate();
1849
1821
  this.tui.requestRender();
1850
1822
  return;
@@ -1858,6 +1830,7 @@ class UsageBreakdownComponent implements Component {
1858
1830
  ) {
1859
1831
  const direction = matchesKey(data, Key.left) || data.toLowerCase() === "h" ? -1 : 1;
1860
1832
  this.rangeIndex = (this.rangeIndex + RANGE_DAYS.length + direction) % RANGE_DAYS.length;
1833
+ this.scrollOffset = 0;
1861
1834
  this.invalidate();
1862
1835
  this.tui.requestRender();
1863
1836
  return;
@@ -1866,6 +1839,7 @@ class UsageBreakdownComponent implements Component {
1866
1839
  if (data === "[" || data === "]" || data.toLowerCase() === "p" || data.toLowerCase() === "n") {
1867
1840
  const direction = data === "[" || data.toLowerCase() === "p" ? -1 : 1;
1868
1841
  this.tabIndex = (this.tabIndex + this.data.tabs.length + direction) % this.data.tabs.length;
1842
+ this.scrollOffset = 0;
1869
1843
  this.ensureLiveUsageLoaded();
1870
1844
  this.invalidate();
1871
1845
  this.tui.requestRender();
@@ -1882,6 +1856,7 @@ class UsageBreakdownComponent implements Component {
1882
1856
  const index = views.indexOf(this.view);
1883
1857
  const direction = matchesKey(data, Key.up) || data.toLowerCase() === "k" ? -1 : 1;
1884
1858
  this.view = views[(index + views.length + direction) % views.length] ?? "model";
1859
+ this.scrollOffset = 0;
1885
1860
  this.invalidate();
1886
1861
  this.tui.requestRender();
1887
1862
  return;
@@ -1890,12 +1865,93 @@ class UsageBreakdownComponent implements Component {
1890
1865
  const rangeByKey: Record<string, number> = { "1": 0, "2": 1, "3": 2 };
1891
1866
  if (Object.hasOwn(rangeByKey, data)) {
1892
1867
  this.rangeIndex = rangeByKey[data]!;
1868
+ this.scrollOffset = 0;
1893
1869
  this.invalidate();
1894
1870
  this.tui.requestRender();
1895
1871
  }
1896
1872
  }
1897
1873
 
1898
1874
  render(width: number): string[] {
1875
+ this.renderWidth = width;
1876
+ const height = Math.max(0, this.tui.terminal.rows - 2);
1877
+ // Four chrome rows plus at least one document row. Margins are supplied by the overlay.
1878
+ if (height < 5 || width < 24) {
1879
+ this.viewportHeight = 0;
1880
+ return ["q close · resize", "Usage needs 26×7"]
1881
+ .slice(0, height)
1882
+ .map((line) => truncateToWidth(line, width));
1883
+ }
1884
+ const document = this.renderDocument(width);
1885
+ const body = document.slice(2);
1886
+ this.viewportHeight = height - 4;
1887
+ this.maxScroll = Math.max(0, body.length - this.viewportHeight);
1888
+ this.scrollOffset = Math.min(this.scrollOffset, this.maxScroll);
1889
+ const visible = body.slice(this.scrollOffset, this.scrollOffset + this.viewportHeight);
1890
+ const keys = (action: "pageUp" | "pageDown") =>
1891
+ this.keybindings.getKeys(`tui.altScreen.${action}`).join("/") || "unbound";
1892
+ return [
1893
+ ...document.slice(0, 2),
1894
+ ...visible,
1895
+ truncateToWidth(dim(`${keys("pageUp")}/${keys("pageDown")} scroll`), width),
1896
+ truncateToWidth(
1897
+ dim(
1898
+ `q close · ${this.scrollOffset + 1}–${this.scrollOffset + visible.length}/${body.length}`,
1899
+ ),
1900
+ width,
1901
+ ),
1902
+ ];
1903
+ }
1904
+
1905
+ private async close(): Promise<void> {
1906
+ if (this.closing) return;
1907
+ this.closing = true;
1908
+ for (const controller of this.liveUsageControllers.values()) {
1909
+ controller.abort();
1910
+ }
1911
+ await Promise.allSettled(this.liveUsageRequests.values());
1912
+ this.onDone();
1913
+ }
1914
+
1915
+ private ensureLiveUsageLoaded(): void {
1916
+ const tab = this.data.tabs[this.tabIndex];
1917
+ const provider = tab?.liveProvider;
1918
+ if (!provider) return;
1919
+
1920
+ const current = this.data.liveUsage.get(provider.id);
1921
+ if (current && current.status !== "loading") return;
1922
+ if (this.liveUsageRequests.has(provider.id)) return;
1923
+
1924
+ const controller = new AbortController();
1925
+ this.liveUsageControllers.set(provider.id, controller);
1926
+ this.data.liveUsage.set(provider.id, { status: "loading" });
1927
+ this.invalidate();
1928
+ this.tui.requestRender();
1929
+
1930
+ const request = this.loadLiveUsage(provider, controller.signal)
1931
+ .then((state) => {
1932
+ if (controller.signal.aborted) return;
1933
+ this.data.liveUsage.set(provider.id, state);
1934
+ })
1935
+ .catch((error) => {
1936
+ if (controller.signal.aborted) return;
1937
+ this.data.liveUsage.set(provider.id, {
1938
+ status: "error",
1939
+ error: error instanceof Error ? error.message : String(error),
1940
+ });
1941
+ })
1942
+ .finally(() => {
1943
+ this.liveUsageRequests.delete(provider.id);
1944
+ this.liveUsageControllers.delete(provider.id);
1945
+ if (!controller.signal.aborted) {
1946
+ this.invalidate();
1947
+ this.tui.requestRender();
1948
+ }
1949
+ });
1950
+
1951
+ this.liveUsageRequests.set(provider.id, request);
1952
+ }
1953
+
1954
+ private renderDocument(width: number): string[] {
1899
1955
  if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
1900
1956
 
1901
1957
  const tab = this.data.tabs[this.tabIndex]!;
@@ -1938,26 +1994,25 @@ class UsageBreakdownComponent implements Component {
1938
1994
  `${viewTab("model", "model")}${viewTab("cwd", "cwd")}${viewTab("dow", "dow")}${viewTab("tod", "tod")}`;
1939
1995
 
1940
1996
  const lines: string[] = [];
1941
- lines.push(truncateToWidth(header, width));
1942
- lines.push(
1943
- truncateToWidth(dim("[/] provider · ←/→ range · ↑/↓ view · tab metric · q to close"), width),
1944
- );
1945
- lines.push(truncateToWidth(`${bold(tab.label)} ${dim(historySemanticsText(tab))}`, width));
1997
+ const compactHeader = `${bold("Usage")} ${providerBadge} [${selectedDays}d] [${this.measurement === "sessions" ? "sess" : this.measurement === "messages" ? "msg" : "tok"}] [${this.view}]`;
1998
+ lines.push(truncateToWidth(visibleWidth(header) <= width ? header : compactHeader, width));
1999
+ const navigation = dim("[/] provider · ←/→ range · ↑/↓ view · tab metric · q to close");
2000
+ lines.push(truncateToWidth(navigation, width));
2001
+ if (visibleWidth(compactHeader) > width) lines.push(compactHeader);
2002
+ if (visibleWidth(navigation) > width) lines.push(navigation);
2003
+ lines.push(`${bold(tab.label)} ${dim(historySemanticsText(tab))}`);
1946
2004
  lines.push("");
1947
2005
 
1948
2006
  const liveLines = renderLiveUsageLines(tab, liveState, width);
1949
2007
  if (liveLines.length > 0) {
1950
- for (const line of liveLines) lines.push(truncateToWidth(line, width));
2008
+ lines.push(...liveLines);
1951
2009
  lines.push("");
1952
2010
  }
1953
2011
 
1954
2012
  const graphDescriptor =
1955
2013
  this.view === "dow" ? `share of ${metric.kind} by weekday` : `${metric.kind}/day`;
1956
2014
  lines.push(
1957
- truncateToWidth(
1958
- `${rangeSummary(range, selectedDays, metric.kind)}${dim(` (graph: ${graphDescriptor})`)}`,
1959
- width,
1960
- ),
2015
+ `${rangeSummary(range, selectedDays, metric.kind)}${dim(` (graph: ${graphDescriptor})`)}`,
1961
2016
  );
1962
2017
  lines.push("");
1963
2018
 
@@ -2078,16 +2133,19 @@ class UsageBreakdownComponent implements Component {
2078
2133
  : this.view === "cwd"
2079
2134
  ? "Top directories (30d palette):"
2080
2135
  : "Time of day:";
2081
- lines.push(truncateToWidth(dim(legendTitle), width));
2082
- for (const item of legendItems) lines.push(truncateToWidth(item, width));
2136
+ lines.push(dim(legendTitle));
2137
+ lines.push(...legendItems);
2083
2138
  }
2084
2139
  }
2085
2140
 
2086
2141
  lines.push("");
2087
- for (const line of tableLines) lines.push(truncateToWidth(line, width));
2142
+ lines.push(...tableLines);
2088
2143
 
2089
2144
  this.cachedWidth = width;
2090
- this.cachedLines = lines;
2145
+ this.cachedLines = [
2146
+ ...lines.slice(0, 2),
2147
+ ...lines.slice(2).flatMap((line) => wrapTextWithAnsi(line, width)),
2148
+ ];
2091
2149
  return this.cachedLines;
2092
2150
  }
2093
2151
  }
@@ -2197,10 +2255,11 @@ export default function usageBreakdownExtension(pi: ExtensionAPI) {
2197
2255
  }
2198
2256
 
2199
2257
  await ctx.ui.custom<void>(
2200
- (tui, _theme, _kb, done) =>
2201
- new UsageBreakdownComponent(data, tui, done, (provider, signal) =>
2258
+ (tui, _theme, keybindings, done) =>
2259
+ new UsageBreakdownComponent(data, tui, keybindings, done, (provider, signal) =>
2202
2260
  fetchLiveUsageForProvider(provider, liveUsageAvailability, ctx, signal),
2203
2261
  ),
2262
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 1 } },
2204
2263
  );
2205
2264
  },
2206
2265
  });
@@ -8,7 +8,14 @@ Optionally create `websearch.json` in your Pi agent directory (normally `~/.pi/a
8
8
 
9
9
  ```json
10
10
  {
11
- "routes": ["pi:openai-codex", "pi:anthropic", "pi:gemini", "firefox:gemini", "chromium:gemini"],
11
+ "routes": [
12
+ "pi:openai-codex",
13
+ "pi:openai",
14
+ "pi:anthropic",
15
+ "pi:gemini",
16
+ "firefox:gemini",
17
+ "chromium:gemini"
18
+ ],
12
19
  "profiles": {
13
20
  "firefox": "default-release",
14
21
  "chromium": "Default"
@@ -8,6 +8,7 @@ const WEBSEARCH_CONFIG_PATH = path.join(getAgentDir(), "websearch.json");
8
8
 
9
9
  const DEFAULT_ROUTES: WebsearchRouteId[] = [
10
10
  "pi:openai-codex",
11
+ "pi:openai",
11
12
  "pi:anthropic",
12
13
  "pi:gemini",
13
14
  "firefox:gemini",
@@ -26,7 +27,7 @@ function sanitizeRoutes(value: unknown): WebsearchRouteId[] {
26
27
  for (const item of value) {
27
28
  if (item === "firefox:openai-codex" || item === "chromium:openai-codex") {
28
29
  throw new Error(
29
- `Invalid websearch route: ${item} (removed). Use pi:openai-codex and sign in with Pi /login.`,
30
+ `Invalid websearch route: ${item} (removed). Use pi:openai-codex or pi:openai and sign in with Pi /login.`,
30
31
  );
31
32
  }
32
33
  if (typeof item !== "string" || !valid.has(item as WebsearchRouteId)) {
@@ -19,7 +19,11 @@ import { limitOutput } from "./output.js";
19
19
  import { isPiAnthropicModel, searchWithPiAnthropic } from "./providers/anthropic.pi.js";
20
20
  import { browserGemini } from "./providers/gemini.browser.js";
21
21
  import { isPiGeminiModel, searchWithPiGemini } from "./providers/gemini.pi.js";
22
- import { isPiOpenAICodexModel, searchWithPiOpenAICodex } from "./providers/openai-codex.pi.js";
22
+ import {
23
+ isPiOpenAICodexModel,
24
+ isPiOpenAIModel,
25
+ searchWithPiOpenAI,
26
+ } from "./providers/openai.pi.js";
23
27
  import type { PiModelSelection } from "./providers/pi-model.shared.js";
24
28
  import { getPiModelCandidates, selectNextPiModel } from "./providers/pi-model.shared.js";
25
29
  import { isModelUnavailableError } from "./providers/shared.js";
@@ -37,8 +41,13 @@ type PiRouteId = `pi:${WebsearchBackendId}`;
37
41
  const PI_ROUTE_HANDLERS: Record<PiRouteId, PiRouteHandler> = {
38
42
  "pi:openai-codex": {
39
43
  predicate: isPiOpenAICodexModel,
40
- search: searchWithPiOpenAICodex,
41
- fallbackModels: ["gpt-5.6-luna", "gpt-5.5"],
44
+ search: searchWithPiOpenAI,
45
+ fallbackModels: ["gpt-6-luna", "gpt-6.1-sol"],
46
+ },
47
+ "pi:openai": {
48
+ predicate: isPiOpenAIModel,
49
+ search: searchWithPiOpenAI,
50
+ fallbackModels: ["gpt-6-luna", "gpt-6.1-sol"],
42
51
  },
43
52
  "pi:anthropic": {
44
53
  predicate: isPiAnthropicModel,
@@ -246,24 +255,24 @@ export default function (pi: ExtensionAPI) {
246
255
  query: Type.String({ description: "What to search for" }),
247
256
  }),
248
257
  renderShell: "self",
249
- renderCall(args, theme) {
250
- return new Text(formatWebsearchCall(args.query, theme), 0, 0);
258
+ renderCall(args, theme, { outputPad }) {
259
+ return new Text(formatWebsearchCall(args.query, theme), outputPad, 0);
251
260
  },
252
- renderResult(result, { expanded }, theme) {
261
+ renderResult(result, { expanded }, theme, { outputPad }) {
253
262
  const content = result.content.find((item) => item.type === "text");
254
263
  const text = content?.type === "text" ? content.text : "";
255
264
 
256
265
  if (!expanded) {
257
- return new Text(formatCollapsedWebsearchResult(text, theme), 0, 0);
266
+ return new Text(formatCollapsedWebsearchResult(text, theme), outputPad, 0);
258
267
  }
259
268
 
260
269
  if (!text.trim()) {
261
- return new Text(`\n${theme.fg("muted", "(no output)")}`, 0, 0);
270
+ return new Text(`\n${theme.fg("muted", "(no output)")}`, outputPad, 0);
262
271
  }
263
272
 
264
273
  const container = new Container();
265
274
  container.addChild(new Spacer(1));
266
- container.addChild(new Markdown(text.trim(), 0, 0, getMarkdownTheme()));
275
+ container.addChild(new Markdown(text.trim(), outputPad, 0, getMarkdownTheme()));
267
276
  return container;
268
277
  },
269
278
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -6,19 +6,20 @@ import type { PiModelSelection } from "./pi-model.shared.js";
6
6
  import { buildWebsearchPrompt, WEBSEARCH_SYSTEM_PROMPT } from "./search-prompt.shared.js";
7
7
  import { applyResolvedHeaders, getResolvedHeader, readEventStream, withTimeout } from "./shared.js";
8
8
 
9
- export async function searchWithPiOpenAICodex(
9
+ export async function searchWithPiOpenAI(
10
10
  selection: PiModelSelection,
11
11
  query: string,
12
12
  signal?: AbortSignal,
13
13
  ): Promise<WebsearchResult> {
14
14
  const apiKey = resolveApiKey(selection);
15
15
  if (!apiKey) {
16
- throw new Error("OpenAI Codex auth is not configured.");
16
+ throw new Error("OpenAI auth is not configured.");
17
17
  }
18
18
 
19
- const result = await runOpenAICodexSearch({
19
+ const backend = selection.model.provider === "openai-codex" ? "openai-codex" : "openai";
20
+ const result = await runOpenAISearch({
20
21
  apiKey,
21
- accountId: decodeJwtAccountId(apiKey),
22
+ backend,
22
23
  model: selection.model.id,
23
24
  query,
24
25
  baseUrl: selection.model.baseUrl,
@@ -27,13 +28,17 @@ export async function searchWithPiOpenAICodex(
27
28
  });
28
29
 
29
30
  return {
30
- backend: "openai-codex",
31
+ backend,
31
32
  authSource: "pi",
32
33
  answer: result.answer,
33
34
  sources: result.sources,
34
35
  };
35
36
  }
36
37
 
38
+ export function isPiOpenAIModel(model: Model<Api>): boolean {
39
+ return model.api === "openai-responses" && model.provider === "openai";
40
+ }
41
+
37
42
  export function isPiOpenAICodexModel(model: Model<Api>): boolean {
38
43
  return model.api === "openai-codex-responses" && model.provider === "openai-codex";
39
44
  }
@@ -48,25 +53,27 @@ function resolveApiKey(selection: PiModelSelection): string | undefined {
48
53
  return match?.[1];
49
54
  }
50
55
 
51
- async function runOpenAICodexSearch(options: {
56
+ async function runOpenAISearch(options: {
52
57
  apiKey: string;
58
+ backend: "openai" | "openai-codex";
53
59
  model: string;
54
60
  query: string;
55
61
  baseUrl?: string;
56
- accountId?: string;
57
62
  headers?: Record<string, string | null>;
58
63
  signal?: AbortSignal;
59
64
  }): Promise<{ answer: string; sources: WebsearchSource[] }> {
60
- const response = await fetch(resolveCodexUrl(options.baseUrl), {
65
+ const codex = options.backend === "openai-codex";
66
+ const accountId = codex ? decodeJwtAccountId(options.apiKey) : undefined;
67
+ const url = codex ? resolveCodexUrl(options.baseUrl) : resolveResponsesUrl(options.baseUrl);
68
+ const response = await fetch(url, {
61
69
  method: "POST",
62
70
  headers: applyResolvedHeaders(
63
71
  {
64
72
  authorization: `Bearer ${options.apiKey}`,
65
- ...(options.accountId ? { "chatgpt-account-id": options.accountId } : {}),
73
+ ...(accountId ? { "chatgpt-account-id": accountId } : {}),
66
74
  "content-type": "application/json",
67
75
  accept: "text/event-stream",
68
- "OpenAI-Beta": "responses=experimental",
69
- originator: "pi-websearch",
76
+ ...(codex ? { "OpenAI-Beta": "responses=experimental", originator: "pi-websearch" } : {}),
70
77
  },
71
78
  options.headers,
72
79
  ),
@@ -82,6 +89,7 @@ async function runOpenAICodexSearch(options: {
82
89
  signal: withTimeout(options.signal, 120_000),
83
90
  });
84
91
 
92
+ const sources: WebsearchSource[] = [];
85
93
  let answer = "";
86
94
  let fallbackAnswer = "";
87
95
  let completed = false;
@@ -110,6 +118,7 @@ async function runOpenAICodexSearch(options: {
110
118
  .filter((text): text is string => typeof text === "string")
111
119
  .join("\n");
112
120
  if (fullText) fallbackAnswer = fullText;
121
+ sources.push(...extractAnnotationSources(content));
113
122
  }
114
123
 
115
124
  if (
@@ -119,7 +128,7 @@ async function runOpenAICodexSearch(options: {
119
128
  ) {
120
129
  const result = event.response as Record<string, unknown> | undefined;
121
130
  if (event.type === "response.incomplete" || result?.status !== "completed") {
122
- throw new Error("OpenAI Codex search did not complete successfully.");
131
+ throw new Error("OpenAI search did not complete successfully.");
123
132
  }
124
133
  completed = true;
125
134
  }
@@ -137,7 +146,7 @@ async function runOpenAICodexSearch(options: {
137
146
  throw new Error((error as Record<string, unknown>).message as string);
138
147
  }
139
148
  }
140
- throw new Error(eventMessage ?? "OpenAI Codex search failed.");
149
+ throw new Error(eventMessage ?? "OpenAI search failed.");
141
150
  }
142
151
  } catch (error) {
143
152
  if (error instanceof SyntaxError) return;
@@ -146,20 +155,46 @@ async function runOpenAICodexSearch(options: {
146
155
  });
147
156
 
148
157
  if (!completed) {
149
- throw new Error("OpenAI Codex stream ended before search completed.");
158
+ throw new Error("OpenAI stream ended before search completed.");
150
159
  }
151
160
 
152
161
  const finalAnswer = (answer || fallbackAnswer).trim();
153
162
  if (!finalAnswer) {
154
- throw new Error("OpenAI Codex returned an empty response.");
163
+ throw new Error("OpenAI returned an empty response.");
155
164
  }
156
165
 
157
166
  return {
158
167
  answer: finalAnswer,
159
- sources: dedupeSources(extractMarkdownSources(finalAnswer)),
168
+ sources: dedupeSources([...sources, ...extractMarkdownSources(finalAnswer)]),
160
169
  };
161
170
  }
162
171
 
172
+ function extractAnnotationSources(content: unknown[]): WebsearchSource[] {
173
+ const sources: WebsearchSource[] = [];
174
+ for (const part of content) {
175
+ if (!part || typeof part !== "object" || !("annotations" in part)) continue;
176
+ if (!Array.isArray(part.annotations)) continue;
177
+ for (const annotation of part.annotations) {
178
+ if (
179
+ annotation?.type !== "url_citation" ||
180
+ typeof annotation.url !== "string" ||
181
+ !/^https?:\/\//i.test(annotation.url)
182
+ )
183
+ continue;
184
+ sources.push({
185
+ url: annotation.url,
186
+ title: typeof annotation.title === "string" ? annotation.title : "",
187
+ });
188
+ }
189
+ }
190
+ return sources;
191
+ }
192
+
193
+ function resolveResponsesUrl(baseUrl?: string): string {
194
+ const normalized = (baseUrl || "https://api.openai.com/v1").replace(/\/+$/, "");
195
+ return normalized.endsWith("/responses") ? normalized : `${normalized}/responses`;
196
+ }
197
+
163
198
  function resolveCodexUrl(baseUrl = "https://chatgpt.com/backend-api"): string {
164
199
  const normalized = String(baseUrl || "https://chatgpt.com/backend-api").replace(/\/+$/, "");
165
200
  if (normalized.endsWith("/codex/responses")) return normalized;
@@ -7,15 +7,22 @@ export function isModelUnavailableError(error: unknown, model: Model<Api>): bool
7
7
  if (!isRecord(error.body)) return false;
8
8
 
9
9
  const detail = isRecord(error.body.error) ? error.body.error : undefined;
10
- if (model.provider === "openai-codex" && model.api === "openai-codex-responses") {
10
+ const codex = model.provider === "openai-codex" && model.api === "openai-codex-responses";
11
+ const openai = model.provider === "openai" && model.api === "openai-responses";
12
+ if (codex || openai) {
11
13
  return (
12
- (error.status === 400 &&
14
+ (codex &&
15
+ error.status === 400 &&
13
16
  Boolean(model.id) &&
14
17
  error.body.detail ===
15
18
  `The '${model.id}' model is not supported when using Codex with a ChatGPT account.`) ||
16
19
  detail?.code === "model_not_found" ||
17
20
  detail?.code === "model_not_supported" ||
18
- detail?.code === "unsupported_model"
21
+ detail?.code === "unsupported_model" ||
22
+ (openai &&
23
+ error.status === 400 &&
24
+ detail?.code === "subscription_sharing_unsupported_capability" &&
25
+ detail.param === "model")
19
26
  );
20
27
  }
21
28
 
@@ -1,8 +1,9 @@
1
- export type WebsearchBackendId = "openai-codex" | "anthropic" | "gemini";
1
+ export type WebsearchBackendId = "openai-codex" | "openai" | "anthropic" | "gemini";
2
2
  export type WebsearchAuthSource = "pi" | "firefox" | "chromium";
3
3
  export type WebsearchBrowserFamily = "firefox" | "chromium";
4
4
  export type WebsearchRouteId =
5
5
  | "pi:openai-codex"
6
+ | "pi:openai"
6
7
  | "pi:anthropic"
7
8
  | "pi:gemini"
8
9
  | "firefox:gemini"