@gloom-sh/terminal 0.19.0 → 0.19.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 (203) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/package.json +1 -1
  4. package/patches/electrobun@1.18.1.patch +23 -1
  5. package/src/api-client/app-attention.ts +3 -0
  6. package/src/api-client/beneficial-owners.ts +3 -0
  7. package/src/api-client/company-kpis.ts +3 -0
  8. package/src/api-client/data.ts +2 -1
  9. package/src/api-client/hiring.ts +3 -0
  10. package/src/api-client/index.ts +4 -0
  11. package/src/api-client/ipo.ts +24 -1
  12. package/src/api-client/mna.ts +3 -0
  13. package/src/api-client/paths.ts +9 -1
  14. package/src/api-client/roadmap.ts +85 -0
  15. package/src/api-client/types.ts +12 -0
  16. package/src/architecture/core-builtin-allowlist.json +2 -2
  17. package/src/cli/commands/market.ts +35 -12
  18. package/src/cli/commands/news-search.ts +22 -0
  19. package/src/cli/commands/overview.ts +86 -20
  20. package/src/cli/commands/ticker.ts +32 -2
  21. package/src/cli/errors.ts +17 -2
  22. package/src/cli/glossary.ts +6 -0
  23. package/src/cli/index-roots.ts +0 -7
  24. package/src/cli/listing-arg.ts +57 -10
  25. package/src/cli/pane-functions/capabilities.ts +1 -0
  26. package/src/cli/pane-functions/discovery.ts +21 -1
  27. package/src/cli/pane-functions/freshness.ts +7 -1
  28. package/src/cli/pane-functions/function-help.ts +7 -0
  29. package/src/cli/pane-functions/index.ts +6 -3
  30. package/src/cli/quote-answer.ts +1 -1
  31. package/src/cli/saved-copy-persistence.ts +88 -0
  32. package/src/components/command-bar/list/model.ts +5 -0
  33. package/src/components/command-bar/routes/root/runtime.ts +6 -7
  34. package/src/components/command-bar/selection-runtime.ts +52 -3
  35. package/src/components/command-bar/surface/index.tsx +2 -0
  36. package/src/components/layout/pane/capture-stamp.ts +2 -2
  37. package/src/components/layout/pane/footer/index.tsx +22 -6
  38. package/src/components/layout/pane/footer/model.ts +83 -15
  39. package/src/components/layout/pane/status-footer.ts +35 -2
  40. package/src/components/layout/status-widget-space.ts +9 -0
  41. package/src/components/ticker/list-table-view.tsx +4 -20
  42. package/src/components/ui/display.tsx +18 -3
  43. package/src/components/ui/icon.tsx +4 -1
  44. package/src/data/plugin-cache.ts +5 -3
  45. package/src/data/saved-copies.ts +8 -0
  46. package/src/docs/snapshot.json +32 -5
  47. package/src/i18n/es.ts +2 -4
  48. package/src/i18n/ja.ts +2 -4
  49. package/src/i18n/ko.ts +2 -4
  50. package/src/i18n/zh-cn.ts +2 -4
  51. package/src/i18n/zh-tw.ts +2 -4
  52. package/src/market-data/depositary-pair.ts +25 -1
  53. package/src/market-data/quotes/metadata.ts +7 -0
  54. package/src/news/aggregator.ts +7 -1
  55. package/src/news/types.ts +6 -0
  56. package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
  57. package/src/plugins/builtin/alerts/cli.ts +34 -11
  58. package/src/plugins/builtin/alerts/event-rows.ts +24 -3
  59. package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
  60. package/src/plugins/builtin/alerts/headless.ts +10 -6
  61. package/src/plugins/builtin/cds/client.ts +142 -30
  62. package/src/plugins/builtin/cds/headless.ts +35 -8
  63. package/src/plugins/builtin/cds/model.ts +145 -18
  64. package/src/plugins/builtin/cds/pane.tsx +16 -5
  65. package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
  66. package/src/plugins/builtin/chart-composer/index.tsx +4 -5
  67. package/src/plugins/builtin/chat/controller/index.ts +5 -1
  68. package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
  69. package/src/plugins/builtin/chat/controller/state.ts +25 -2
  70. package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
  71. package/src/plugins/builtin/chat/controller/utils.ts +7 -2
  72. package/src/plugins/builtin/chat/status-preview.ts +79 -0
  73. package/src/plugins/builtin/chat/status-widget.tsx +147 -10
  74. package/src/plugins/builtin/chat/unread-badge.ts +51 -10
  75. package/src/plugins/builtin/clinical-trials/client.ts +49 -4
  76. package/src/plugins/builtin/clinical-trials/model.ts +86 -22
  77. package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
  78. package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
  79. package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
  80. package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
  81. package/src/plugins/builtin/company-attention/headless.ts +2 -1
  82. package/src/plugins/builtin/company-attention/pane.tsx +3 -0
  83. package/src/plugins/builtin/company-kpis/headless.ts +8 -3
  84. package/src/plugins/builtin/company-kpis/model.ts +73 -5
  85. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  86. package/src/plugins/builtin/composite-plugins.ts +2 -1
  87. package/src/plugins/builtin/credit-documents/headless.ts +3 -3
  88. package/src/plugins/builtin/doe/headless.ts +3 -2
  89. package/src/plugins/builtin/doe/pane.tsx +13 -3
  90. package/src/plugins/builtin/earnings-calls/data.ts +18 -6
  91. package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
  92. package/src/plugins/builtin/earnings-calls/pane.tsx +7 -0
  93. package/src/plugins/builtin/econ/calendar-model.ts +48 -1
  94. package/src/plugins/builtin/econ/calendar-source.ts +4 -2
  95. package/src/plugins/builtin/econ/detail-view.tsx +2 -2
  96. package/src/plugins/builtin/econ/figures.ts +48 -0
  97. package/src/plugins/builtin/econ/index.tsx +14 -5
  98. package/src/plugins/builtin/econ/print-watch.ts +3 -1
  99. package/src/plugins/builtin/econ-statistics/client.ts +11 -0
  100. package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
  101. package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
  102. package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
  103. package/src/plugins/builtin/help/index.tsx +61 -59
  104. package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
  105. package/src/plugins/builtin/help/table-cells.tsx +13 -2
  106. package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
  107. package/src/plugins/builtin/holders/beneficial-view.tsx +3 -0
  108. package/src/plugins/builtin/holders/headless.ts +4 -2
  109. package/src/plugins/builtin/holders/pane.tsx +3 -0
  110. package/src/plugins/builtin/holders/report-rows.ts +4 -1
  111. package/src/plugins/builtin/insider/headless.ts +2 -2
  112. package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
  113. package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
  114. package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
  115. package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
  116. package/src/plugins/builtin/macro-day/headless.ts +12 -5
  117. package/src/plugins/builtin/macro-day/index.tsx +1 -1
  118. package/src/plugins/builtin/macro-day/model.ts +39 -5
  119. package/src/plugins/builtin/macro-day/pane.tsx +27 -19
  120. package/src/plugins/builtin/market-movers/client.ts +2 -0
  121. package/src/plugins/builtin/market-movers/headless.ts +3 -1
  122. package/src/plugins/builtin/market-movers/screener.ts +8 -4
  123. package/src/plugins/builtin/mna/headless.ts +2 -0
  124. package/src/plugins/builtin/mna/pane.tsx +3 -1
  125. package/src/plugins/builtin/news/headless.ts +3 -1
  126. package/src/plugins/builtin/news/index.tsx +3 -0
  127. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
  128. package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
  129. package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
  130. package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
  131. package/src/plugins/builtin/realized-vol/client.ts +13 -5
  132. package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
  133. package/src/plugins/builtin/realized-vol/headless.ts +8 -4
  134. package/src/plugins/builtin/realized-vol/model.ts +37 -6
  135. package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
  136. package/src/plugins/builtin/research/event-model.ts +30 -4
  137. package/src/plugins/builtin/research/index.tsx +7 -3
  138. package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
  139. package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
  140. package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
  141. package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
  142. package/src/plugins/builtin/risk-factors/data.ts +4 -2
  143. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  144. package/src/plugins/builtin/roadmap/index.tsx +28 -0
  145. package/src/plugins/builtin/roadmap/model.ts +67 -0
  146. package/src/plugins/builtin/roadmap/pane.tsx +244 -0
  147. package/src/plugins/builtin/scanner/live-headless.ts +21 -2
  148. package/src/plugins/builtin/sec/headless.ts +2 -1
  149. package/src/plugins/builtin/sec/index.tsx +3 -0
  150. package/src/plugins/builtin/shared/cloud-resource.ts +13 -5
  151. package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
  152. package/src/plugins/builtin/shared/report-freshness.ts +8 -0
  153. package/src/plugins/builtin/shared/research-cells.tsx +1 -1
  154. package/src/plugins/builtin/shared/series-cache.ts +4 -4
  155. package/src/plugins/builtin/thirteenf/api.ts +12 -1
  156. package/src/plugins/builtin/thirteenf/data.ts +7 -7
  157. package/src/plugins/builtin/thirteenf/headless.ts +31 -4
  158. package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
  159. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
  160. package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
  161. package/src/plugins/builtin/treasury-auctions/headless.ts +21 -12
  162. package/src/plugins/builtin/treasury-auctions/pane.tsx +9 -3
  163. package/src/plugins/builtin/volatility/pane.tsx +5 -2
  164. package/src/plugins/builtin/yield-curve/client.ts +61 -1
  165. package/src/plugins/builtin/yield-curve/headless.ts +9 -6
  166. package/src/plugins/builtin/yield-curve/index.tsx +34 -10
  167. package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
  168. package/src/plugins/catalog-browser.ts +2 -1
  169. package/src/plugins/host-link.ts +1 -1
  170. package/src/portfolio/column-values.ts +11 -7
  171. package/src/renderers/dom/desktop/icons.tsx +7 -0
  172. package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
  173. package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
  174. package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
  175. package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
  176. package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
  177. package/src/renderers/electrobun/bun/index.ts +26 -2
  178. package/src/renderers/electrobun/bun/window/create.ts +43 -4
  179. package/src/renderers/electrobun/bun/window/events.ts +20 -0
  180. package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
  181. package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
  182. package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
  183. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  184. package/src/renderers/electrobun/view/news-source.ts +2 -0
  185. package/src/renderers/electrobun/view/ui-host.tsx +8 -0
  186. package/src/sources/gloomberb-cloud/fred-series.ts +3 -1
  187. package/src/sources/gloomberb-cloud/index.ts +42 -8
  188. package/src/tickers/company-name-listing.ts +53 -13
  189. package/src/tickers/company-name.ts +6 -1
  190. package/src/tickers/index-roots.ts +7 -0
  191. package/src/tickers/listing-fate.ts +2 -2
  192. package/src/tickers/search/index.ts +6 -2
  193. package/src/tickers/search/ranking.ts +5 -1
  194. package/src/tickers/search/upsert.ts +13 -0
  195. package/src/tickers/ticker-changes.ts +8 -4
  196. package/src/types/company-from.ts +40 -0
  197. package/src/types/data-provider.ts +7 -0
  198. package/src/types/financials.ts +27 -0
  199. package/src/types/headless.ts +6 -0
  200. package/src/utils/crypto-pair.ts +121 -7
  201. package/src/utils/declared-dividends.ts +1 -1
  202. package/src/utils/fundamentals.ts +36 -5
  203. package/src/version.ts +2 -2
@@ -1,8 +1,8 @@
1
1
  import { logFailure } from "../../utils/log-failure";
2
2
  import { apiClient } from "../../api-client";
3
- import { ApiRequestError } from "../../api-client/errors";
3
+ import { ApiRequestError, isPermanentClientError } from "../../api-client/errors";
4
4
  import type { CliCommandDef } from "../../types/plugin";
5
- import { withCliServices, withMarketData } from "../context";
5
+ import { withCliServices, withConfigData, withMarketData } from "../context";
6
6
  import { CLI_ERROR_CODE, failWithFields } from "../errors";
7
7
  import type { CliResultColumn } from "../result";
8
8
  import { formatCompact } from "../../utils/format";
@@ -25,17 +25,27 @@ import {
25
25
  import { MOVER_CANDIDATES } from "../../plugins/builtin/market-movers/client";
26
26
  import { hasPartialFloat, meetsMinFloat, minFloatShares, normalizeMinFloat, PARTIAL_FLOAT_NOTE } from "../../plugins/builtin/market-movers/min-float";
27
27
  import { fetchEconCalendarFrom } from "../../plugins/builtin/econ/calendar-source";
28
- import { computedFredSeriesNote, seriesNotOnFred } from "../../sources/gloomberb-cloud/fred-series";
28
+ import {
29
+ attachFredSeriesPersistence, computedFredSeriesNote, loadCachedFredSeries, resetFredSeriesPersistence, seriesNotOnFred,
30
+ } from "../../sources/gloomberb-cloud/fred-series";
31
+ import type { CloudFredSeriesPayload } from "../../api-client";
32
+ import { savedCopyMark } from "../../data/saved-copies";
33
+ import { ratesMacroPluginMeta } from "../../plugins/builtin/builtin-plugin-meta";
34
+ import { createCommandPluginPersistence } from "../saved-copy-persistence";
29
35
  import { EXTENDED_SESSION_LABELS, getQuoteSessionFields, type ExtendedSession } from "../../market-data/market/status";
30
36
  import {
31
37
  COUNTRY_CYCLE,
32
38
  COUNTRY_FILTER_HINT,
39
+ awaitsConsensus,
33
40
  countriesWithoutEvents,
41
+ econEventName,
34
42
  FILTER_CYCLE,
35
43
  loadCalendar,
36
44
  eventCountry,
37
45
  matchesCountry,
46
+ matchesEventSearch,
38
47
  matchesImpact,
48
+ NO_CONSENSUS_NOTE,
39
49
  noScheduledReleasesMessage,
40
50
  parseCountryFilter,
41
51
  shortCalendarEnd,
@@ -303,7 +313,7 @@ function utcDateTimePart(value: unknown, part: "date" | "time", readerClock = tr
303
313
  return part === "date" ? iso.slice(0, 10) : `${iso.slice(11, 16)} UTC${readerClock ? localTimeSuffix(date.getTime()) : ""}`;
304
314
  }
305
315
 
306
- const ECON_USAGE = "econ [--country <regions or countries>] [--impact <level>] [--from <date>] [--tz <zone>]";
316
+ const ECON_USAGE = "econ [--country <regions or countries>] [--impact <level>] [--search <words>] [--from <date>] [--tz <zone>]";
307
317
  const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
308
318
  /** "Sat" for 2026-10-31. */
309
319
  const weekdayOf = (date: string) => WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()]!;
@@ -470,13 +480,15 @@ async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>
470
480
  const rawArgs = [...args];
471
481
  const country = parseCountry(takeOption(rawArgs, "--country"), ctx);
472
482
  const impact: ImpactFilter = parseFilter(takeOption(rawArgs, "--impact"), FILTER_CYCLE, "impact", ctx);
483
+ const search = takeOption(rawArgs, "--search")?.trim() ?? null;
484
+ if (search === "") ctx.fail("--search needs the words of an event's name.", "Such as econ --search nonfarm.");
473
485
  const fromOption = parseDateOption(takeOption(rawArgs, "--from"), "--from", ctx);
474
486
  const tzOption = takeOption(rawArgs, "--tz");
475
487
  const viewerZone = tzOption == null ? null : canonicalTimeZone(tzOption);
476
488
  if (tzOption != null && !viewerZone) {
477
489
  ctx.fail(`Unknown time zone "${tzOption}".`, "Use an IANA zone name, such as America/New_York, Europe/London or Asia/Tokyo.");
478
490
  }
479
- rejectArgs(rawArgs, ECON_USAGE, ctx, "Filter with --country and --impact.");
491
+ rejectArgs(rawArgs, ECON_USAGE, ctx, "Filter with --country, --impact and --search.");
480
492
  await withCliServices(ctx, async () => {
481
493
  const now = Date.now();
482
494
  const todayStart = Math.floor(now / DAY_MS) * DAY_MS;
@@ -487,7 +499,9 @@ async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>
487
499
  : null;
488
500
  const past = stored?.events ? stored : null;
489
501
  const unread = stored && !stored.events && stored.failure ? `The stored calendar could not be read (${stored.failure}); listing the current calendar` : null;
490
- const { data: events, fetchedAt, stale } = past ? { data: past.events, fetchedAt: Date.now(), stale: false } : await loadCalendar(ctx.cliOptions.refresh);
502
+ const { data: events, fetchedAt, stale, source } = past
503
+ ? { data: past.events, fetchedAt: Date.now(), stale: false, source: "network" as const }
504
+ : await loadCalendar(ctx.cliOptions.refresh);
491
505
  // The calendar lists from today (UTC). --from moves the first day; --tail
492
506
  // alone keeps the newest events of the whole calendar, the past week's too.
493
507
  const from = fromOption ?? (ctx.cliOptions.tail != null ? null : new Date(todayStart).toISOString().slice(0, 10));
@@ -506,9 +520,10 @@ async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>
506
520
  // Countries asked for that nothing in the window is for: a quiet week there, not an unknown country.
507
521
  const inWindow = events.filter((event) => event.date.getTime() >= fromTime);
508
522
  const unscheduled = countriesWithoutEvents(country, inWindow);
509
- const rows = inWindow
510
- .filter((event) => matchesCountry(event, country) && matchesImpact(event, impact))
511
- .sort((left, right) => left.date.getTime() - right.date.getTime())
523
+ const listed = inWindow
524
+ .filter((event) => matchesCountry(event, country) && matchesImpact(event, impact) && (search == null || matchesEventSearch(event, search)))
525
+ .sort((left, right) => left.date.getTime() - right.date.getTime());
526
+ const rows = listed
512
527
  .map((event) => {
513
528
  // The viewer's clock (--tz) for a row with a time: its date and time there.
514
529
  const clock = viewerZone && !hasNoReleaseTime(event) ? zonedClock(event.date.getTime(), viewerZone) : null;
@@ -522,7 +537,8 @@ async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>
522
537
  ...(viewerZone ? { viewerDate: clock?.date ?? null, viewerTime: clock?.time ?? null } : {}),
523
538
  country: eventCountry(event),
524
539
  impact: event.impact,
525
- event: event.event,
540
+ // As the calendar names it: the Fed's decision is the upper bound of its range.
541
+ event: econEventName(event),
526
542
  actual: figures.actual,
527
543
  forecast: figures.forecast,
528
544
  prior: figures.prior,
@@ -553,15 +569,20 @@ async function runEcon(args: string[], ctx: Parameters<CliCommandDef["execute"]>
553
569
  // A filter of countries only, none of which has anything in the window, says so as its empty list.
554
570
  const allUnscheduled = unscheduled.length > 0 && unscheduled.length === country.split(",").length;
555
571
  const notes = [listedBefore, listedAfter, ...(rows.length > 0 ? blankTimeNotes(rows) : []),
556
- rows.length > 0 && unscheduled.length > 0 ? noScheduledReleasesMessage(unscheduled) : null]
572
+ rows.length > 0 && unscheduled.length > 0 ? noScheduledReleasesMessage(unscheduled) : null,
573
+ // A dash under Forecast on a release still to come is a consensus not in yet, not a missing figure.
574
+ listed.some(awaitsConsensus) ? NO_CONSENSUS_NOTE : null]
557
575
  .filter((note): note is string => !!note);
558
576
  const footnote = notes.join("\n");
577
+ // The calendar as read, not its rows: those are scheduled, mostly ahead. One that could not be
578
+ // refreshed is the copy an earlier read saved, and says when that was.
579
+ const read = rowsFreshness([], { status: "not-a-feed", basis: "calendar", asOf: fetchedAt }, { stale });
580
+ const calendarFreshness = read && source === "stale-fallback" ? { ...read, savedAt: new Date(fetchedAt).toISOString() } : read;
559
581
  ctx.printResult({
560
582
  data: rows,
561
583
  ...(unread ? { warnings: [unread] } : {}),
562
- metadata: { country, impact, from, listedThrough, storedFrom: past?.storedFrom ?? null, ...(viewerZone ? { timeZone: viewerZone } : {}) },
563
- // The calendar as read, not its rows: those are scheduled, mostly ahead.
564
- freshness: rowsFreshness([], { status: "not-a-feed", basis: "calendar", asOf: fetchedAt }, { stale }),
584
+ metadata: { country, impact, ...(search != null ? { search } : {}), from, listedThrough, storedFrom: past?.storedFrom ?? null, ...(viewerZone ? { timeZone: viewerZone } : {}) },
585
+ freshness: calendarFreshness,
565
586
  }, {
566
587
  dateKey: "date",
567
588
  defaultLimit: 50,
@@ -607,6 +628,36 @@ function isUnsupportedSeries(error: unknown): boolean {
607
628
  return error.details?.code === "unsupported_series" || /^Unsupported FRED series\b/i.test(message);
608
629
  }
609
630
 
631
+ /**
632
+ * Runs `run` with the FRED series cache saving what it fetches in the profile,
633
+ * where the app's rates and macro panes keep it: a refresh that fails then
634
+ * answers from the copy an earlier read saved, and the report says so.
635
+ */
636
+ function withSavedFredSeries<T>(ctx: Parameters<CliCommandDef["execute"]>[1], run: () => Promise<T>): Promise<T> {
637
+ return withConfigData(ctx, async ({ persistence }) => {
638
+ attachFredSeriesPersistence(createCommandPluginPersistence(persistence.resources, `plugin:${ratesMacroPluginMeta.stateId}`));
639
+ try {
640
+ return await run();
641
+ } finally {
642
+ resetFredSeriesPersistence();
643
+ }
644
+ });
645
+ }
646
+
647
+ /**
648
+ * A FRED series from Gloom Cloud, or the copy an earlier read saved when it
649
+ * cannot be refreshed (marked as such). A refusal asking again will not change
650
+ * (a series Gloom Cloud no longer serves, access denied) is said, not covered.
651
+ */
652
+ async function loadFredSeries(
653
+ seriesId: string,
654
+ params: { startDate?: string; limit?: number; sortOrder: "asc" | "desc" },
655
+ ): Promise<CloudFredSeriesPayload> {
656
+ const result = await loadCachedFredSeries({ seriesId, ...params }, () => apiClient.getCloudFredSeries(seriesId, params));
657
+ if (result.source === "stale-fallback" && isPermanentClientError(result.error)) throw result.error;
658
+ return result.data;
659
+ }
660
+
610
661
  async function runFredList(filter: string, ctx: Parameters<CliCommandDef["execute"]>[1]) {
611
662
  let catalog;
612
663
  try {
@@ -652,9 +703,19 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
652
703
  ctx.fail(change ? `Unknown change "${change}".` : "--change needs 1D or 1W.", "Use 1D (from the previous observation) or 1W (from a week before).");
653
704
  }
654
705
  const seriesId = requireOneArg(args, FRED_USAGE, "series", ctx).toUpperCase();
706
+ await withSavedFredSeries(ctx, () => printFredSeries(ctx, { seriesId, start, startDate, sortOrder, horizon }));
707
+ }
708
+
709
+ async function printFredSeries(ctx: Parameters<CliCommandDef["execute"]>[1], { seriesId, start, startDate, sortOrder, horizon }: {
710
+ seriesId: string;
711
+ start: string | undefined;
712
+ startDate: string;
713
+ sortOrder: "asc" | "desc";
714
+ horizon: ReturnType<typeof parseFredChange>;
715
+ }) {
655
716
  // A change needs the observation it is measured from: the fetch reaches back before the first row shown.
656
717
  const fetchStart = horizon ? fredChangeFetchStart(startDate) : startDate;
657
- const data = await apiClient.getCloudFredSeries(seriesId, { startDate: fetchStart, sortOrder }).catch((error: unknown) => {
718
+ const data = await loadFredSeries(seriesId, { startDate: fetchStart, sortOrder }).catch((error: unknown) => {
658
719
  if (isUnsupportedSeries(error)) ctx.fail(`Unsupported FRED series ${seriesId}. List supported series with: gloom fred --list`);
659
720
  throw error;
660
721
  });
@@ -668,7 +729,9 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
668
729
  const releasedOf = (observation: { publisher?: string; publishedAt?: string; releasedOn?: string }) =>
669
730
  observation.releasedOn ?? (observation.publisher?.trim() ? observation.publishedAt : undefined);
670
731
  // A value FRED does not list yet carries its publisher and when it published, in every export.
732
+ // A row from the copy an earlier read saved, the refresh having failed, carries its mark: the source line says so.
671
733
  const exportRow = (observation: { date: string; value: number | null; publisher?: string; publishedAt?: string; releasedOn?: string }) => ({
734
+ ...savedCopyMark(data),
672
735
  date: observation.date,
673
736
  value: fredExportValue(seriesId, observation.value),
674
737
  ...(changes ? { change: changes.get(observation.date)?.change ?? null, changeFrom: changes.get(observation.date)?.changeFrom ?? null } : {}),
@@ -687,7 +750,7 @@ async function runFred(rawArgs: string[], ctx: Parameters<CliCommandDef["execute
687
750
  computedFredSeriesNote(seriesId),
688
751
  ].filter((note): note is string => note != null);
689
752
  // Nothing on or after the start: name where the series ends, so an empty answer is not a dead end.
690
- const latest = rows.length > 0 ? null : await apiClient.getCloudFredSeries(seriesId, { limit: 1, sortOrder: "desc" })
753
+ const latest = rows.length > 0 ? null : await loadFredSeries(seriesId, { limit: 1, sortOrder: "desc" })
691
754
  .then((payload) => payload.observations[0] ?? null).catch(logFailure("cli.commands-overview", "Read latest economic series observation failed", null));
692
755
  // CSV and NDJSON are rows alone: an empty one would read as a series with no data, so it fails and says where the series ends.
693
756
  if (rows.length === 0 && (ctx.cliOptions.format === "csv" || ctx.cliOptions.format === "ndjson")) {
@@ -759,10 +822,12 @@ async function runYieldCurve(rawArgs: string[], ctx: Parameters<CliCommandDef["e
759
822
  const startDate = parseStartDate(takeOption(args, "--start"), ctx);
760
823
  rejectArgs(args, "yield-curve [--start <date>]", ctx);
761
824
  const series = Object.keys(YIELD_TENORS);
762
- const results = await Promise.all(series.map(async (seriesId) => {
763
- const data = await apiClient.getCloudFredSeries(seriesId, { startDate, sortOrder: "desc" });
825
+ const results = await withSavedFredSeries(ctx, () => Promise.all(series.map(async (seriesId) => {
826
+ const data = await loadFredSeries(seriesId, { startDate, sortOrder: "desc" });
764
827
  const [latest, previous] = data.observations;
765
828
  return {
829
+ // A tenor from the copy an earlier read saved, the refresh having failed, says so in the source line.
830
+ ...savedCopyMark(data),
766
831
  seriesId,
767
832
  date: latest?.date ?? "",
768
833
  // Yields in percent, which the exports' `currency_or_unit` column says (`pct`), as `fred` does.
@@ -775,7 +840,7 @@ async function runYieldCurve(rawArgs: string[], ctx: Parameters<CliCommandDef["e
775
840
  latest,
776
841
  publishedAt: latest?.publisher ? null : fredPublicationTime(data.info, latest?.date),
777
842
  };
778
- }));
843
+ })));
779
844
  // Tenors whose latest yield FRED does not list yet name the Treasury and when it published them.
780
845
  const ahead = fredProvenance(results.flatMap((row) => row.latest ? [row.latest] : []));
781
846
  // The curve shown was published with its last tenor: the newest of their FRED updates, when each has one.
@@ -907,10 +972,11 @@ export const overviewCliCommands: CliCommandDef[] = [
907
972
  options: [
908
973
  { flags: "--country <regions or countries>", description: "US, G7, EU, all (default), or countries by their two-letter codes, comma separated, such as MX or MX,BR,ZA" },
909
974
  { flags: "--impact <level>", description: "high, medium, low, or all (default all)" },
975
+ { flags: "--search <words>", description: "Events whose name has these words, case, spaces and hyphens aside, such as nonfarm or fed funds" },
910
976
  { flags: "--from <date>", description: `First day listed, as ${DATE_OPTION_EXAMPLES} (default today, UTC); an earlier day lists past events from the stored calendar and says where it starts` },
911
977
  { flags: "--tz <zone>", description: "Add a column with each release's time on that clock, an IANA zone such as America/New_York; with the weekday when it falls on another day than the UTC date" },
912
978
  ],
913
- examples: ["econ", "econ --country US --impact high", "econ --country MX,BR,ZA,IN,KR", "econ --from 2026-10-12 --country US",
979
+ examples: ["econ", "econ --country US --impact high", "econ --search nonfarm", "econ --country MX,BR,ZA,IN,KR", "econ --from 2026-10-12 --country US",
914
980
  "econ --country BR --tz America/New_York", "econ --tail 20"],
915
981
  },
916
982
  execute: runEcon,
@@ -83,7 +83,7 @@ import { movedAwayChange } from "../../tickers/ticker-changes";
83
83
  import { companyMemberships, loadCompanyMemberships, type CompanyMemberships, type ListMembership } from "../issuer-membership";
84
84
  import { isNotATickerMessage } from "../not-a-ticker";
85
85
  import { sharesOutstandingInReceipts } from "../../utils/depositary-receipt";
86
- import { depositaryPairText, isReceiptSide, listingDepositaryPair } from "../../market-data/depositary-pair";
86
+ import { cedearRatioText, depositaryPairText, isReceiptSide, listingCedearRatio, listingDepositaryPair } from "../../market-data/depositary-pair";
87
87
  import { cliFreshnessFooter } from "../result";
88
88
  import { providerMissReason } from "../../sources/provider-errors";
89
89
  import { exportEntriesTable, reportFooterLines, type CliReportTables } from "../report-tables";
@@ -100,6 +100,7 @@ import { bondFigures, NOT_SEC_YIELD } from "../../market-data/bond-funds";
100
100
  import { readFundFacts } from "../../market-data/fund-facts";
101
101
  import { taxEquivalentLines, type TaxEquivalentAnalysis } from "../../market-data/tax-equivalent";
102
102
  import { isRoundTheClockCoin } from "../../utils/crypto-pair";
103
+ import { ipoDetailNotes, ipoMetricRows, loadTickerIpo, type TickerIpo } from "../../plugins/builtin/ipo-calendar/listing-ipo";
103
104
  import { coinNewsFallbackNotice } from "../../sources/gloomberb-cloud/news";
104
105
 
105
106
  const NEWS_ITEM_LIMIT = 5;
@@ -126,6 +127,8 @@ const METADATA_SEPARATOR = " · ";
126
127
  interface TickerCommandDependencies {
127
128
  /** `--exchange`: the listing, for a symbol that trades in several places. */
128
129
  exchange?: string;
130
+ /** A stock's IPO of the last year with its lock-up news; a failure leaves the IPO section out. */
131
+ listingIpo?: (listing: { symbol: string; exchange?: string | null }) => Promise<TickerIpo | null>;
129
132
  initMarketData?: () => Promise<MarketContext>;
130
133
  fail?: (message: string, details?: string) => never;
131
134
  printResult?: CliCommandContext["printResult"];
@@ -1081,8 +1084,11 @@ export async function buildTickerReport({
1081
1084
  fundFacts = null,
1082
1085
  memberships,
1083
1086
  offerings = [],
1087
+ ipo = null,
1084
1088
  }: {
1085
1089
  symbol: string;
1090
+ /** A stock's IPO of the last year, with its lock-up estimate and news (loadTickerIpo). */
1091
+ ipo?: TickerIpo | null;
1086
1092
  /** The lists holding the company, by any of its listings (loadCompanyMemberships); the saved listing's own lists when absent. */
1087
1093
  memberships?: CompanyMemberships;
1088
1094
  /** The filings behind the latest statements, and a newer quarter's results release. */
@@ -1165,6 +1171,9 @@ export async function buildTickerReport({
1165
1171
  if (depositaryPair) {
1166
1172
  lines.push(cliStyles.muted(`ADR ratio ${depositaryPairText(depositaryPair, isReceiptSide(financials))}. Implied FX and premium: gloom fn ADR ${quote?.symbol ?? symbol}`));
1167
1173
  }
1174
+ // A CEDEAR states its ratio in its company's shares, as a receipt does.
1175
+ const cedearRatio = listingCedearRatio(financials);
1176
+ if (cedearRatio) lines.push(cliStyles.muted(`CEDEAR ratio ${cedearRatioText(cedearRatio)}.`));
1168
1177
 
1169
1178
  // A list that holds the company as another of its listings says which: "Watchlists R (as RCI.B:TSX)".
1170
1179
  const lists = memberships ?? companyMemberships(config, tickerFile, [], null);
@@ -1250,7 +1259,15 @@ export async function buildTickerReport({
1250
1259
  ]);
1251
1260
  }
1252
1261
 
1253
- const metrics = fundamentalsMetrics(quote, priced.fundamentals, profile, { text: marketCapText }, priceReturns, fundamentalsCurrency(financials),
1262
+ // A stock that listed in the last year: its IPO, the move since, and when its lock-up is estimated to end.
1263
+ if (ipo) {
1264
+ appendMetricSection(lines, "IPO", ipoMetricRows(ipo.deal, quote ? getRegularSessionDisplay(quote)?.price : null));
1265
+ for (const note of ipoDetailNotes(ipo.deal, ipo.news)) lines.push(cliStyles.muted(note));
1266
+ }
1267
+
1268
+ // A listing of the last year may have no statements yet: its IPO says how recent it is.
1269
+ const listing = { listedSince: ipo ? ipo.deal.listingDate ?? ipo.deal.firstDay?.session ?? null : null };
1270
+ const metrics = fundamentalsMetrics(quote, priced.fundamentals, profile, { text: marketCapText }, priceReturns, fundamentalsCurrency(financials, listing),
1254
1271
  { text: enterpriseValueText }, distributions, bondFund, codes);
1255
1272
  const reportedCurrency = financials.financialCurrency?.trim();
1256
1273
  const statements = [...financials.annualStatements, ...financials.quarterlyStatements];
@@ -1349,9 +1366,11 @@ function buildTickerStructuredData({
1349
1366
  bondFund = null,
1350
1367
  memberships,
1351
1368
  offerings = [],
1369
+ ipo = null,
1352
1370
  }: {
1353
1371
  symbol: string;
1354
1372
  listing: CliListing;
1373
+ ipo?: TickerIpo | null;
1355
1374
  memberships: CompanyMemberships;
1356
1375
  statementFilings: StatementFilingsReport | null;
1357
1376
  offerings?: readonly OfferingFiling[];
@@ -1434,6 +1453,8 @@ function buildTickerStructuredData({
1434
1453
  // and the offering prospectuses filed since that the cash does not include.
1435
1454
  cashRunway: runway,
1436
1455
  offeringsAfterBalanceSheet: runway ? offeringsAfter(offerings, runway.periodEnd) : [],
1456
+ // A stock that listed in the last year: its IPO deal (offer price, first day, lock-up estimate) and lock-up news.
1457
+ ...ipo ? { ipo: { ...ipo.deal, lockupNews: ipo.news } } : {},
1437
1458
  // The form and filed date of the latest periods' filings, and a newer quarter's results released before its statements.
1438
1459
  statementFilings,
1439
1460
  notes,
@@ -1462,6 +1483,8 @@ function buildTickerStructuredData({
1462
1483
  }
1463
1484
 
1464
1485
  function depositaryPairJson(financials: TickerFinancials) {
1486
+ const cedear = listingCedearRatio(financials);
1487
+ if (cedear) return { cedearRatio: { ...cedear, text: cedearRatioText(cedear) } };
1465
1488
  const pair = listingDepositaryPair(financials);
1466
1489
  if (!pair) return {};
1467
1490
  const receipt = isReceiptSide(financials);
@@ -1573,6 +1596,11 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1573
1596
  });
1574
1597
  // An offering since the latest balance sheet, read only when the report has a runway to flag.
1575
1598
  const offerings = loadOfferingFilings(statementSources, { symbol: requestSymbol, exchange }, cashRunway(resolvedFinancials) != null);
1599
+ // A stock's IPO of the last year, read beside the rest; it never fails the report.
1600
+ const ipoLoad = quote && resolveTickerInstrumentKind(tickerFile, resolvedFinancials) === "equity"
1601
+ ? (dependencies.listingIpo ?? loadTickerIpo)({ symbol: requestSymbol, exchange: exchange || quote.listingExchangeName || quote.exchangeName })
1602
+ .catch(logFailure("cli.commands-ticker", "Read a listing's IPO failed", null))
1603
+ : Promise.resolve(null);
1576
1604
  const [notesResult, newsResult, secFilingsResult] = await Promise.allSettled([
1577
1605
  notesFiles.load(tickerFile?.metadata.ticker ?? normalized),
1578
1606
  dataProvider.getNews({
@@ -1640,6 +1668,7 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1640
1668
  bondFund: await bondFund,
1641
1669
  memberships,
1642
1670
  offerings: await offerings,
1671
+ ipo: await ipoLoad,
1643
1672
  }),
1644
1673
  });
1645
1674
  return;
@@ -1666,6 +1695,7 @@ export async function ticker(symbol: string, dependencies: TickerCommandDependen
1666
1695
  fundFacts: await fundFacts,
1667
1696
  memberships,
1668
1697
  offerings: await offerings,
1698
+ ipo: await ipoLoad,
1669
1699
  }));
1670
1700
  if (freshness) console.log(`\n${cliFreshnessFooter(freshness, quote ?? resolvedFinancials)}`);
1671
1701
  });
package/src/cli/errors.ts CHANGED
@@ -13,7 +13,7 @@ export const CLI_ERROR_CODE = "cli_error";
13
13
  /** The code of a command that ran, and the data service has nothing for what it asked. */
14
14
  export const NO_DATA_CODE = "no_data";
15
15
  /** The code of a command whose requests never reached the data service. */
16
- export const SERVICE_UNREACHABLE_CODE = "service_unreachable";
16
+ const SERVICE_UNREACHABLE_CODE = "service_unreachable";
17
17
 
18
18
  /** What to check when nothing answered: the connection, and the proxy when one is set. */
19
19
  function unreachableHint(): string {
@@ -74,6 +74,21 @@ export function failRetryable(message: string, code: string): never {
74
74
  throw new CliFailure(message, undefined, code, true);
75
75
  }
76
76
 
77
+ /**
78
+ * What a command says when its requests never reached the data service:
79
+ * `message` names what it could not get (by default only that), and the
80
+ * details what to check. `--json` marks it retryable.
81
+ */
82
+ export function unreachableError(message = DATA_SERVICE_UNREACHABLE_MESSAGE): CliErrorObject {
83
+ return { code: SERVICE_UNREACHABLE_CODE, message, details: unreachableHint(), retryable: true };
84
+ }
85
+
86
+ /** Fails as a command whose requests never reached the data service (`unreachableError`). */
87
+ export function failUnreachable(message?: string): never {
88
+ const error = unreachableError(message);
89
+ throw new CliFailure(error.message, error.details, error.code, error.retryable);
90
+ }
91
+
77
92
  export function closeAndFail(persistence: AppPersistence, message: string, details?: string): never {
78
93
  persistence.close();
79
94
  fail(message, details);
@@ -104,7 +119,7 @@ function cliErrorObject(error: unknown): CliErrorObject {
104
119
  }
105
120
  // No answer came back at all: in Gloom's words, whichever fetch failed (Bun's "Unable to connect", a refused proxy).
106
121
  if (isNetworkFailure(error)) {
107
- return { code: SERVICE_UNREACHABLE_CODE, message: DATA_SERVICE_UNREACHABLE_MESSAGE, details: unreachableHint(), retryable: true };
122
+ return unreachableError();
108
123
  }
109
124
  // Gloom Cloud refused what was asked (an unknown series, a bad date): the input was wrong, not the program.
110
125
  if (error instanceof ApiRequestError && error.status === 400) {
@@ -437,6 +437,12 @@ export const GLOSSARY: readonly GlossaryEntry[] = [
437
437
  definition: "How many ordinary shares one depositary receipt holds: 1 ADR = 10 ordinary shares means one GGAL in New York is ten GGAL.BA shares in Buenos Aires. "
438
438
  + "A receipt's price compares with its home price only once the home price is multiplied by the ratio.",
439
439
  },
440
+ {
441
+ term: "CEDEAR ratio", aliases: ["cedear", "cedears", "certificado de deposito argentino"],
442
+ functions: ["DES"], shown: ["CEDEAR ratio", "CEDEAR Ratio"],
443
+ definition: "How many Buenos Aires CEDEARs make the company's shares they stand for, from BYMA's published list: 20 AAPL:BYMA = 1 Apple Inc. share. "
444
+ + "A CEDEAR's market cap is its company's, in the currency of the listing it stands for (Apple's in dollars), not the certificate's peso price times a share count.",
445
+ },
440
446
  {
441
447
  term: "ADR premium", aliases: ["adr discount", "adr vs home", "receipt premium", "adr premium/discount"],
442
448
  functions: ["ADR"], shown: ["ADR vs home", "premium", "discount"],
@@ -35,10 +35,3 @@ export function indexRootTryLine(root: IndexRoot, command = "quote"): string {
35
35
  export function indexRootAlsoLine(root: IndexRoot, command = "quote"): string {
36
36
  return `also ${root.symbol} ${root.name} (gloom ${command} ${root.symbol})`;
37
37
  }
38
-
39
- /** Cboe's Treasury yield indices: 13-week bill, 5-, 10- and 30-year notes. Each level is a yield in percent. */
40
- const YIELD_INDEX_SYMBOLS: ReadonlySet<string> = new Set(["^IRX", "^FVX", "^TNX", "^TYX"]);
41
-
42
- export function isYieldIndexSymbol(symbol: string): boolean {
43
- return YIELD_INDEX_SYMBOLS.has(symbol.trim().toUpperCase());
44
- }
@@ -17,7 +17,7 @@ import {
17
17
  import { findUsListingOfCompany, type UsListing } from "../tickers/us-listing";
18
18
  import { findListingOfCompanyName, isCompanyNameInput, readTerminalCodeArg, type CompanyNameListing } from "../tickers/company-name-listing";
19
19
  import { movedAwayChange, startTickerChangeLookup, type MovedTickerChange } from "../tickers/ticker-changes";
20
- import { loadListingFate, MovedSymbolError, type ListingFate } from "../tickers/listing-fate";
20
+ import { lineMayHaveMoved, loadListingFate, MovedSymbolError, type ListingFate } from "../tickers/listing-fate";
21
21
  import { quotedListingName } from "../tickers/listing-name";
22
22
  import { cliStyles } from "../utils/cli-output";
23
23
  import { groupByCompany, namesDepositaryReceipt, sameCompany, sameListedName, shortCompanyName } from "../tickers/company-name";
@@ -36,7 +36,7 @@ import { withMarketData } from "./scoped-context";
36
36
  import { failWithFields } from "./errors";
37
37
  import { stripStrayEdges } from "./symbol-args";
38
38
  import { isNoProviderMessage, isNoSourceAnswer, isProviderBusy } from "../sources/provider-errors";
39
- import { dashedCoinPair, MAJOR_COIN_NAMES } from "../utils/crypto-pair";
39
+ import { dashedCoinPair, exchangeLineCoinListing, isSpotCommodityOrCurrencyPair, MAJOR_COIN_NAMES } from "../utils/crypto-pair";
40
40
  import { boardCoinFor, primeBoardCoins } from "./board-coins";
41
41
  import { knownGapMessage } from "./known-gaps";
42
42
  import { currencyPairSpelling } from "../utils/currency-pair";
@@ -107,6 +107,12 @@ export interface ListingArg {
107
107
  * `readAs` asks about it, and a command the listing fails asks too.
108
108
  */
109
109
  meant?: { symbol: string; name: string };
110
+ /**
111
+ * A bare symbol a company's name was read as still says what it was read
112
+ * as (`listingChoiceNotes`): GETYQ for "getty images", whose NYSE line
113
+ * became GETYQ. Other bare symbols say where the data service chose instead.
114
+ */
115
+ readFromName?: true;
110
116
  }
111
117
 
112
118
  /** "Did you mean JPM (JPMorgan Chase & Co.)?", for a name read as a fund or an index; empty for any other listing. */
@@ -176,8 +182,13 @@ function parseCleanListingArg(
176
182
  // `SAN:` asks which listing; with --exchange it has its answer.
177
183
  if (option && typed.endsWith(":")) typed = typed.slice(0, -1);
178
184
  const venueKey = parsePublicTickerKey(typed);
179
- // BTC/USD, as crypto venues write a pair, is the coin's own BTC-USD.
180
- const parsed = { ...venueKey, symbol: dashedCoinPair(venueKey.symbol) ?? venueKey.symbol };
185
+ // A crypto exchange's line of a pair (BTC/GBP:COINBASE PRO, as search offered it) is the crypto venue's BTC-GBP:CCC.
186
+ const line = venueKey.exchange || optionCode ? exchangeLineCoinListing(venueKey.symbol, venueKey.exchange ?? optionCode) : null;
187
+ if (line) return { symbol: line.symbol, exchange: line.exchange, key: `${line.symbol}:${line.exchange}` };
188
+ // BTC/USD, as crypto venues write a pair, is the coin's own BTC-USD; spot crude (WTI/USD) and gold by the gram
189
+ // (GAU/USD:COMMODITY) are not coins, though tokens share their letters.
190
+ const coinPair = isSpotCommodityOrCurrencyPair(venueKey.symbol, venueKey.exchange ?? optionCode) ? null : dashedCoinPair(venueKey.symbol);
191
+ const parsed = { ...venueKey, symbol: coinPair ?? venueKey.symbol };
181
192
  const ownCode = parsed.exchange ? typedExchangeCode(typed) : "";
182
193
  let exchange = parsed.exchange ?? option;
183
194
  let code = ownCode || optionCode;
@@ -452,7 +463,7 @@ export function coinListingNotes(
452
463
  * its warnings.
453
464
  */
454
465
  export async function listingChoiceNotes(
455
- listing: Pick<CliListing, "symbol" | "exchange" | "saved" | "readAs">,
466
+ listing: Pick<CliListing, "symbol" | "exchange" | "saved" | "readAs" | "readFromName">,
456
467
  deps: ListingDeps,
457
468
  quote: Pick<Quote, "name" | "listingExchangeName" | "exchangeName" | "instrumentType" | "stale" | "delisting"> | null | undefined,
458
469
  /** The venues `loadBareVenues` already started; looked up here when not given. */
@@ -460,6 +471,8 @@ export async function listingChoiceNotes(
460
471
  ): Promise<string[]> {
461
472
  const coin = coinListingNotes(listing, quote);
462
473
  if (!isBareListing(listing)) return [...listingReadNotes(listing), ...coin];
474
+ // A bare symbol a name was read as says so: "Read "getty images" as GETYQ (...). GETY became GETYQ on 2026-10-22."
475
+ if (listing.readFromName) return [...listingReadNotes(listing), ...coin];
463
476
  if (!quote || UNLISTED_TYPES.has(quote.instrumentType?.trim().toUpperCase() ?? "")) return coin;
464
477
  const exchange = canonicalExchange(quote.listingExchangeName || quote.exchangeName);
465
478
  if (!exchange) return coin;
@@ -475,7 +488,7 @@ export async function listingChoiceNotes(
475
488
  * as another listing (BTC, the Grayscale trust) ends with the coin's pair.
476
489
  */
477
490
  export async function quotedListingChoiceNotes(
478
- listing: Pick<CliListing, "symbol" | "exchange" | "saved" | "request" | "readAs">,
491
+ listing: Pick<CliListing, "symbol" | "exchange" | "saved" | "request" | "readAs" | "readFromName">,
479
492
  deps: ListingDeps,
480
493
  ): Promise<string[]> {
481
494
  const venues = loadBareVenues(listing, deps);
@@ -1346,7 +1359,36 @@ function isMajorCoinName(word: string): boolean {
1346
1359
  }
1347
1360
 
1348
1361
  function findCompanyName(typed: string, deps: Pick<ListingDeps, "dataProvider">, options: { oneWord?: boolean } = {}): Promise<CompanyNameListing | null> {
1349
- return findListingOfCompanyName(deps.dataProvider, typed, { meant: true, quoted: true, ...options });
1362
+ return findListingOfCompanyName(deps.dataProvider, typed, { meant: true, quoted: true, movedLine: (listing) => movedLineOf(listing, deps), ...options });
1363
+ }
1364
+
1365
+ /**
1366
+ * Where the company of a US line that stopped trading, or has no quote, trades
1367
+ * now when it moved off the symbol (`loadListingFate`): GETY became GETYQ on
1368
+ * 2026-10-22, so "getty images" is GETYQ rather than its delisted NYSE line or
1369
+ * the Munich line still quoting the old shares. A line that trades is not
1370
+ * looked up.
1371
+ */
1372
+ async function movedLineOf(
1373
+ listing: { symbol: string; exchange: string; quote: Quote | null; name: string | null },
1374
+ deps: Pick<ListingDeps, "dataProvider">,
1375
+ ): Promise<{ symbol: string; note: string } | null> {
1376
+ const { quote, name, ...line } = listing;
1377
+ if (quote && !lineMayHaveMoved(quote)) return null;
1378
+ const fate = await loadListingFate(line, deps.dataProvider, { quote });
1379
+ const moved = fate.moved ?? (quote ? null : movedWithoutQuote(fate.change, name));
1380
+ return moved ? { symbol: moved.tradesAs, note: moved.note } : null;
1381
+ }
1382
+
1383
+ /**
1384
+ * The move of a line with no quote, when it is the move of the company search
1385
+ * names on the line. With no quote nothing tells the company's change from an
1386
+ * older one's: a reused symbol's change data is the company that held it
1387
+ * before, so it is followed only when that company is this one.
1388
+ */
1389
+ function movedWithoutQuote(change: ListingFate["change"], name: string | null): MovedTickerChange | null {
1390
+ const moved = movedAwayChange(change);
1391
+ return moved && sameCompany(name, moved.issuer) ? moved : null;
1350
1392
  }
1351
1393
 
1352
1394
  async function readCompanyName(typed: string, deps: Pick<ListingDeps, "dataProvider">, options: { oneWord?: boolean } = {}): Promise<ListingArg | null> {
@@ -1355,11 +1397,16 @@ async function readCompanyName(typed: string, deps: Pick<ListingDeps, "dataProvi
1355
1397
  }
1356
1398
 
1357
1399
  function companyListingArg(typed: string, found: CompanyNameListing): ListingArg {
1358
- const key = `${found.symbol}:${found.exchange}`;
1400
+ const key = found.exchange ? `${found.symbol}:${found.exchange}` : found.symbol;
1359
1401
  // A fund or an index carrying the name asks about the company's stock: "Did you mean JPM (JPMorgan Chase & Co.)?".
1360
1402
  const meant = found.meant ? { symbol: found.meant.symbol, name: found.meant.name } : null;
1361
1403
  const question = meant ? ` ${meantQuestion({ meant })}` : "";
1362
- return { ...parseListingArg(key), readAs: `Read "${typed.trim()}" as ${key} (${found.name}).${question}`, ...meant ? { meant } : {} };
1404
+ // A company that moved off the line search lists says so: "GETY became GETYQ on 2026-10-22."
1405
+ const moved = found.moved ? ` ${found.moved}.` : "";
1406
+ return {
1407
+ ...parseListingArg(key), readAs: `Read "${typed.trim()}" as ${key} (${found.name}).${moved}${question}`,
1408
+ ...meant ? { meant } : {}, ...found.exchange ? {} : { readFromName: true as const },
1409
+ };
1363
1410
  }
1364
1411
 
1365
1412
  /**
@@ -1377,7 +1424,7 @@ export async function companyNameSuggestion(typed: string, deps: Pick<ListingDep
1377
1424
  const found = isCompanyNameInput(words) ? await findCompanyName(words, deps).catch(busyAsMiss) : await findOneWordCompany(words, deps);
1378
1425
  const listing = found?.meant ?? found;
1379
1426
  if (!listing || listing.symbol.toUpperCase() === words.toUpperCase()) return null;
1380
- return { symbol: isUsListingExchange(listing.exchange) ? listing.symbol : `${listing.symbol}:${listing.exchange}`, name: listing.name };
1427
+ return { symbol: !listing.exchange || isUsListingExchange(listing.exchange) ? listing.symbol : `${listing.symbol}:${listing.exchange}`, name: listing.name };
1381
1428
  } catch (error) {
1382
1429
  logFailure("cli.listing-arg", "Search a company name for a suggestion failed")(error);
1383
1430
  return null;
@@ -48,6 +48,7 @@ const NON_DATA_PANE_IDS = new Set([
48
48
  "local-agent-workspace",
49
49
  "macro-tv",
50
50
  "plugin-marketplace",
51
+ "roadmap",
51
52
  "world-venue-map",
52
53
  ]);
53
54
 
@@ -12,7 +12,9 @@ import type {
12
12
  PluginPersistence,
13
13
  } from "../../types/plugin";
14
14
  import { createPluginTeamState } from "../../plugins/team-state";
15
+ import { bindPluginContextNamespaces } from "../../plugins/registry/plugin-state";
15
16
  import type { MarketContext } from "../types";
17
+ import { createCommandPluginPersistence } from "../saved-copy-persistence";
16
18
  import type { PaneFunctionCatalog } from "./catalog";
17
19
 
18
20
  export interface PaneDiscoveryOptions {
@@ -173,23 +175,41 @@ export async function createPaneCatalog(context: MarketContext, plugins: GloomPl
173
175
  brokers,
174
176
  });
175
177
 
178
+ // Each plugin's caches save what a command fetches where the app keeps them, so a failed refresh answers from them.
179
+ const resources = context.persistence?.resources;
180
+ const persistences: Array<ReturnType<typeof createCommandPluginPersistence>> = [];
181
+ const commandPersistence = (stateId: string) => {
182
+ const persistence = createCommandPluginPersistence(resources, `plugin:${stateId}`);
183
+ persistences.push(persistence);
184
+ return persistence;
185
+ };
186
+
176
187
  const destroy = () => {
177
188
  let failure: unknown;
178
189
  for (const plugin of setupPlugins.splice(0).reverse()) {
179
190
  try { plugin.dispose?.(); } catch (error) { failure ??= error; }
180
191
  }
192
+ // A plugin that keeps its persistence past dispose must not reach a store the command closes.
193
+ for (const persistence of persistences.splice(0)) persistence.release();
181
194
  brokers.clear();
182
195
  if (context.resolveBroker === resolveBroker) context.resolveBroker = previousResolver;
183
196
  if (failure) throw failure;
184
197
  };
185
198
 
199
+ const namespaced = (stateId: string) => ({
200
+ persistence: commandPersistence(stateId),
201
+ resume: discoveryContext.resume,
202
+ configState: discoveryContext.configState,
203
+ teamState: discoveryContext.teamState,
204
+ });
186
205
  try {
187
206
  for (const plugin of plugins) {
188
207
  for (const pane of plugin.panes ?? []) panes.set(pane.id, pane);
189
208
  for (const template of plugin.paneTemplates ?? []) paneTemplates.set(template.id, template);
190
209
  if (plugin.setup) {
191
210
  setupPlugins.push(plugin);
192
- await plugin.setup(discoveryContext);
211
+ const stateId = plugin.stateId ?? plugin.id;
212
+ await plugin.setup(bindPluginContextNamespaces({ ...discoveryContext, ...namespaced(stateId) }, namespaced));
193
213
  }
194
214
  }
195
215
  } catch (error) {