strom-research 1.25.1-beta.1 → 1.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.cs.md CHANGED
@@ -5,9 +5,37 @@
5
5
  Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou na
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (anglicky).
7
7
 
8
+ ## 1.25.2
9
+
10
+ *3. 11. 2026 · patří k aplikaci Strom 3.21.2.*
11
+
12
+ - **Brána týdne předplatného už neukončí běh kvůli jedné nepřečtené odpovědi.** Zeptá se znovu, pak čtvrt hodiny počká
13
+ a řekne, co dostala. Nepřihlášený agent se ohlásí už před prvním sezením i s postupem přihlášení; výchozí složka
14
+ agenta zadaná v prostředí se vynechá, takže agent zůstane u svého obvyklého přihlášení.
15
+ - **Ztotožnění míst: kontrola zapíše, co zjistí, do záznamu místa.** Staré názvy a pravopisy, jiné tvary místa
16
+ v údajích a chybná fara (odebraná i s důvodem) se zapíší do záznamu i tam, kde souřadnice zadala osoba, nikdy jen
17
+ do dějin. Tvar blízko místa, které záznam už má, se odmítne a řekne se, jak ho k tomu záznamu přidat; dva záznamy
18
+ jednoho místa jde sloučit. Na prvním místě je dnešní název (místo dnes v Polsku polsky, německý název jako
19
+ historický). Otázka u místa nikdy nebere osobě její souřadnice. Už ztotožněná místa se jednou samy srovnají (názvy
20
+ zapsané i s jazykem a roky jako text, dnešní název první, souřadnice osoby, staré názvy jen v dějinách) a krok jde
21
+ vrátit. Souhrn sezení o místech začíná tím, co se udělalo – ztotožněno, ověřeno, s otázkou, nedohledáno – a jedno
22
+ sezení vezme víc míst sousedních oblastí. Z „v Lhotě v čp. 12“ už nezůstane místo „v Lhotě v“:
23
+ místem je obec, jak ji výzkum píše jinde, i když ji věta skloňuje. Každý název se do záznamu místa zapíše jen jednou.
24
+ - **Fronta míst se po vyřízení míst vyprázdní.** Místo ztotožněné, ověřené, předané k otázce nebo nedohledané odejde z
25
+ fronty se všemi svými tvary, i s tím, který fakta mezitím píší jinak; místa, která sezení už udělalo, se znovu neberou
26
+ a počet čekajících sedí. Co ve frontě nechala dřívější verze, se odebere samo (jen vyřízená místa, krok jde vrátit);
27
+ kontrola vyžádaná u místa, které čeká na ztotožnění, zůstane jako kontrola, a kontrola čekající u tvaru, který fakta
28
+ nově píší jako jiné místo, přejde s nimi k tomu místu. Každý krok nad místy jde vrátit vlastním
29
+ číslem, i když čeká jiný (také odpovědi člověka). Chybné jméno v záznamu místa — překlep ze záznamu, který už žádný
30
+ fakt nepíše, jméno jiného místa — jde odebrat i s důvodem (`strom place names L… --drop`); sezení nad místy vidí další
31
+ jména záznamu s jazykem a roky; kontrola může uvést, o jaký zdroj se opřela; příslušnost jde zadat i k jednomu roku.
32
+ - V rozporech úpravy z aplikace už číslo domu, které značku má, nedostane značku podruhé (polsky „nr domu nr domu 12“),
33
+ a pomlčka v titulku je podle jazyka výzkumu.
34
+ - Plán toho, co agent udělá dál, už nespadne na čekajícím vyprávění o manželích: jmenuje oba manžele.
35
+
8
36
  ## 1.25.1
9
37
 
10
- *Připravuje se: bety do 1.25.1-beta.1. Oddíl bude úplný s vydáním.*
38
+ *3. 11. 2026 · patří k aplikaci Strom 3.21.1.*
11
39
 
12
40
  - **Podmínky archivů ověřené znovu, přibyly polské zdroje.** Podmínky a robots.txt všech archivů, které strom zná,
13
41
  byly 3. listopadu 2026 přečteny znovu. eBadatelna SOA Praha nově nepouští k digitalizátům žádné roboty (její
package/CHANGELOG.de.md CHANGED
@@ -5,9 +5,46 @@
5
5
  Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Installationszeilen stehen bei
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (auf Englisch).
7
7
 
8
+ ## 1.25.2
9
+
10
+ *3.11.2026 · gehört zur Strom-App 3.21.2.*
11
+
12
+ - **Das Tor der Abo-Woche beendet einen Lauf nicht mehr wegen einer unlesbaren Antwort.** Es fragt erneut, wartet dann
13
+ eine Viertelstunde und nennt, was es erhielt. Ein nicht angemeldeter Agent wird schon vor der ersten Sitzung gemeldet,
14
+ mit dem Weg zur Anmeldung; der in der Umgebung genannte Standardordner des Agenten wird weggelassen, so bleibt der
15
+ Agent bei seiner üblichen Anmeldung.
16
+ - **Orte zuordnen: eine Prüfung schreibt, was sie findet, in den Datensatz des Ortes.** Alte Namen und Schreibweisen,
17
+ andere Formen des Ortes in den Angaben und eine falsche Pfarrei (mit Grund entfernt) kommen in den Datensatz, auch
18
+ wo die Koordinaten von der Person stammen, nie nur in die Geschichte. Eine Form nahe einem Ort, der schon einen
19
+ Datensatz hat, wird abgelehnt, mit dem Weg, sie diesem Datensatz hinzuzufügen; zwei Datensätze eines Ortes lassen sich
20
+ zusammenführen. Der heutige Name steht vorn (ein Ort heute in Polen polnisch, der deutsche Name als historischer).
21
+ Eine Frage zu einem Ort nimmt der Person nie ihre Koordinaten. Schon zugeordnete Orte werden einmal von selbst
22
+ berichtigt (Namen mit Sprache und Jahren als Text, der heutige Name vorn, die Koordinaten der Person, alte Namen nur
23
+ in der Geschichte), und der Schritt lässt sich zurücknehmen. Die Zusammenfassung einer Sitzung zu Orten beginnt mit
24
+ dem, was getan wurde – zugeordnet, bestätigt, mit Frage, nicht gefunden –, und eine Sitzung nimmt mehr Orte
25
+ benachbarter Gebiete. Aus „in Lhota in Nr. 12“ bleibt kein Ort „in Lhota in“ mehr:
26
+ Ort ist die Gemeinde, wie die Forschung sie sonst schreibt, auch wenn der Satz sie beugt. Jeder Name steht nur einmal
27
+ im Eintrag eines Ortes.
28
+ - **Die Warteschlange der Orte leert sich, wenn die Orte erledigt sind.** Ein Ort, der bestimmt, geprüft, erfragt oder
29
+ nicht gefunden wurde, verlässt die Warteschlange mit allen seinen Formen, auch mit einer, die die Fakten inzwischen
30
+ anders schreiben; Orte, die eine Sitzung schon erledigt hat, werden nicht erneut genommen, und die Zahl des Wartenden
31
+ stimmt. Was eine frühere Version in der Warteschlange ließ, wird von selbst entfernt (nur erledigte Orte, der Schritt
32
+ lässt sich rückgängig machen); eine Prüfung, die für einen Ort verlangt wird, der auf die Bestimmung wartet, bleibt
33
+ als Prüfung, und eine Prüfung, die bei einer Form wartet, die die Fakten jetzt als anderen Ort schreiben, geht mit
34
+ ihnen zu diesem Ort. Jeder Schritt bei den Orten lässt sich mit seiner eigenen Nummer rückgängig machen, auch wenn ein anderer
35
+ wartet (auch die Antworten der Person). Ein falscher Name im Datensatz eines Ortes — ein Versehen eines Eintrags, das
36
+ kein Faktum mehr schreibt, der Name eines anderen Ortes — lässt sich mit Begründung entfernen (`strom place names L…
37
+ --drop`); die Sitzung über die Orte sieht die weiteren Namen des Datensatzes mit Sprache und Jahren; eine Prüfung kann
38
+ die Quelle nennen, auf die sie sich stützt; eine Zugehörigkeit lässt sich auch für ein einzelnes Jahr angeben.
39
+ - In den Widersprüchen einer Änderung aus der App bekommt eine Hausnummer, die ihre Bezeichnung schon trägt, sie nicht
40
+ ein zweites Mal (polnisch „nr domu nr domu 12“), und der Gedankenstrich im Titel richtet sich nach der Sprache der
41
+ Forschung.
42
+ - Der Plan dessen, was der Agent als Nächstes tut, bricht bei einer wartenden Geschichte eines Paares nicht mehr ab:
43
+ er nennt beide Partner.
44
+
8
45
  ## 1.25.1
9
46
 
10
- *In Vorbereitung: die Betas bis 1.25.1-beta.1. Der Abschnitt ist mit dem Release vollständig.*
47
+ *3.11.2026 · gehört zur Strom-App 3.21.1.*
11
48
 
12
49
  - **Die Bedingungen der Archive erneut geprüft, polnische Quellen dazu.** Bedingungen und robots.txt jedes Archivs,
13
50
  das strom kennt, wurden am 3. November 2026 neu gelesen. Die eBadatelna des SOA Praha lässt jetzt keinen Roboter
package/CHANGELOG.md CHANGED
@@ -5,9 +5,39 @@ English · [Čeština](CHANGELOG.cs.md) · [Deutsch](CHANGELOG.de.md) · [Polski
5
5
  The changes of each version in short. The full notes and the install lines are on
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases).
7
7
 
8
+ ## 1.25.2
9
+
10
+ *2026-11-03 · goes with the Strom app 3.21.2.*
11
+
12
+ - **The gate of the subscription's week no longer ends a run over one answer it cannot read.** It asks again, then
13
+ waits a quarter of an hour and says what it got. An agent not logged in is said before the first session, with how to
14
+ log in; the agent's default folder named in the environment is left out, so the agent keeps its usual login.
15
+ - **Identifying places: a check writes what it finds into the place's record.** Old names and spellings, the facts'
16
+ other forms of the place and a wrong parish (taken off with the reason) go into the record also where the coordinates
17
+ are the person's, never only into the history. A form close to a place with a record already is refused with the way
18
+ to add it to that record; two records of one place can be merged. Today's name comes first (a place now in Poland
19
+ under its Polish name, the German one as historical). A question on a place never takes the person's coordinates
20
+ away from them. The places identified before are set right once by themselves (names written with their language
21
+ and years as text, today's name first, the person's coordinates, old names kept only in the history), and the step
22
+ can be undone. A session on the places begins its summary with what it did — identified, verified, asked, not
23
+ located — and takes more places of neighbouring areas at once. "in Lhota in no. 12" no longer leaves "in Lhota in" as
24
+ a place: the place is the municipality as the research writes it elsewhere, also where the sentence declined it. Each
25
+ name goes into a place's record once.
26
+ - **The queue of places empties as the places are done.** A place identified, checked, asked about or not located leaves
27
+ the queue with every form of it, also one the facts write otherwise since; the places a session already did are no
28
+ longer taken again, and the count of what waits is right. What an earlier version left in the queue is taken out by
29
+ itself (only places dealt with, the step can be undone); a check asked of a place waiting to be identified is kept as
30
+ the check, and a check waiting for a form the facts now write as another place goes with them to that place. Each step on places is undone by its own number, also while another waits (the person's answers among
31
+ them). A wrong name of a place's record — a slip of a record no fact writes any more, another place's name — can be
32
+ taken off with the reason (`strom place names L… --drop`); the session on the places sees the record's other names
33
+ with their language and years; a check can name the reference it stood on; a jurisdiction can be given for one year.
34
+ - In the conflicts of an edit from the app a house that says it is one already no longer gets its marker twice ("nr domu nr
35
+ domu 12" in Polish), and the title's dash is the one of the research's language.
36
+ - The plan of what the agent does next no longer fails on a couple's story waiting: it names the two partners.
37
+
8
38
  ## 1.25.1
9
39
 
10
- *In preparation: the betas up to 1.25.1-beta.1. The section is complete with the release.*
40
+ *2026-11-03 · goes with the Strom app 3.21.1.*
11
41
 
12
42
  - **The archives' terms checked again, Polish sources added.** The terms and robots.txt of every archive strom knows
13
43
  were read again on 3 November 2026. SOA Praha's eBadatelna now keeps every robot off its scans (its robots.txt of
package/CHANGELOG.pl.md CHANGED
@@ -5,9 +5,41 @@
5
5
  Zmiany każdej wersji w skrócie. Pełne informacje o wydaniach i polecenia instalacji są na
6
6
  [GitHub Releases](https://github.com/ACiDekCZ/strom-research/releases) (po angielsku).
7
7
 
8
+ ## 1.25.2
9
+
10
+ *3.11.2026 · współpracuje z aplikacją Strom 3.21.2.*
11
+
12
+ - **Warunek tygodnia subskrypcji nie kończy już przebiegu z powodu jednej nieodczytanej odpowiedzi.** Pyta ponownie,
13
+ potem czeka kwadrans i podaje, co otrzymał. Niezalogowany agent jest zgłaszany już przed pierwszą sesją, ze sposobem
14
+ logowania; domyślny folder agenta podany w środowisku jest pomijany, więc agent zostaje przy swoim zwykłym logowaniu.
15
+ - **Identyfikacja miejsc: sprawdzenie zapisuje to, co ustali, w rekordzie miejsca.** Dawne nazwy i pisownie, inne formy
16
+ miejsca w danych i błędna parafia (usunięta wraz z powodem) trafiają do rekordu także tam, gdzie współrzędne podała
17
+ osoba, nigdy tylko do historii. Forma blisko miejsca, które ma już rekord, zostaje odrzucona ze wskazaniem, jak ją
18
+ do tego rekordu dodać; dwa rekordy jednego miejsca można scalić. Na pierwszym miejscu jest dzisiejsza nazwa
19
+ (miejscowość dziś w Polsce po polsku, niemiecka nazwa jako historyczna). Pytanie o miejsce nigdy nie odbiera osobie
20
+ jej współrzędnych. Miejsca już zidentyfikowane zostają raz same poprawione (nazwy zapisane z językiem i latami jako
21
+ tekst, dzisiejsza nazwa pierwsza, współrzędne osoby, dawne nazwy tylko w historii), a krok można cofnąć.
22
+ Podsumowanie sesji o miejscach zaczyna się od tego, co zrobiono – zidentyfikowano, potwierdzono, z pytaniem, nie
23
+ odnaleziono – a jedna sesja bierze więcej miejsc z sąsiednich obszarów. Z „w Lhocie w nr 12” nie zostaje już
24
+ miejsce „w Lhocie w”: miejscem jest miejscowość tak, jak badanie zapisuje ją gdzie indziej, także gdy zdanie ją
25
+ odmienia. Każda nazwa trafia do rekordu miejsca tylko raz.
26
+ - **Kolejka miejsc opróżnia się, gdy miejsca są załatwione.** Miejsce zidentyfikowane, sprawdzone, przekazane jako
27
+ pytanie albo nieustalone opuszcza kolejkę ze wszystkimi swoimi formami, także z tą, którą fakty zapisują teraz
28
+ inaczej; miejsca, które sesja już zrobiła, nie są brane ponownie, a liczba oczekujących się zgadza. To, co w kolejce
29
+ zostawiła wcześniejsza wersja, usuwa się samo (tylko załatwione miejsca, krok można cofnąć); sprawdzenie zlecone dla
30
+ miejsca czekającego na identyfikację zostaje jako sprawdzenie, a sprawdzenie czekające przy formie, którą fakty
31
+ zapisują teraz jako inne miejsce, przechodzi z nimi do tego miejsca. Każdy krok przy miejscach można cofnąć jego własnym
32
+ numerem, także gdy czeka inny (również odpowiedzi osoby). Błędną nazwę w rekordzie miejsca — pomyłkę z zapisu, której
33
+ żaden fakt już nie zapisuje, nazwę innego miejsca — można usunąć z uzasadnieniem (`strom place names L… --drop`);
34
+ sesja nad miejscami widzi pozostałe nazwy rekordu z językiem i latami; sprawdzenie może podać źródło, na którym się
35
+ oparło; przynależność można podać także dla jednego roku.
36
+ - W sprzecznościach zmian z aplikacji numer domu, który ma już oznaczenie, nie dostaje go po raz drugi („nr domu nr
37
+ domu 12”), a myślnik w tytule zależy od języka badania.
38
+ - Plan tego, co agent zrobi dalej, nie przerywa się już na oczekującej opowieści o parze: podaje oboje partnerów.
39
+
8
40
  ## 1.25.1
9
41
 
10
- *W przygotowaniu: bety do 1.25.1-beta.1. Sekcja będzie kompletna z wydaniem.*
42
+ *3.11.2026 · współpracuje z aplikacją Strom 3.21.1.*
11
43
 
12
44
  - **Warunki archiwów sprawdzone ponownie, doszły źródła polskie.** Warunki i robots.txt każdego archiwum, które zna
13
45
  strom, zostały 3 listopada 2026 przeczytane na nowo. eBadatelna SOA Praha nie wpuszcza już żadnych robotów do swoich
@@ -809,6 +809,12 @@
809
809
  "places.identify.why": "Osoba o to požádala: která obec toho jména to je, kde leží, její názvy a příslušnost – pro mapu, dějiny a domy.",
810
810
  "places.identify.done": "Každé místo fronty ztotožněné, s otázkou, nebo nedohledané.",
811
811
  "places.identify.result": "každé místo fronty ztotožněné, s otázkou, nebo nedohledané",
812
+ "places.count.identified": "ztotožněno {n}",
813
+ "places.count.verified": "ověřeno {n} (už ztotožněná, zkontrolováno)",
814
+ "places.count.undecided": "zkontrolováno, nerozhodnuto {n}",
815
+ "places.count.asked": "s otázkou {n}",
816
+ "places.count.unlocated": "nedohledáno {n}",
817
+ "places.count.merged": "sloučeno {n}",
812
818
  "history.region.what": "Události dějin v regionu {region}: války, epidemie, sčítání lidu, změny státu, vystěhovalectví",
813
819
  "history.region.where": "encyklopedie, státní instituce, archivy, Wikipedie v jazyce regionu s jejími odkazy",
814
820
  "history.region.why": "co předkové zažili, ukázané u jejich životů",
@@ -2628,6 +2634,7 @@
2628
2634
  "ui.conflict.titleAfter": "titul za jménem",
2629
2635
  "ui.conflict.parents": "rodiče",
2630
2636
  "ui.conflict.removed": "ve stromu",
2637
+ "ui.conflict.title": "{who}: {what} – {ours} × {theirs}",
2631
2638
  "ui.conflict.removed.ours": "odebrání v aplikaci Strom ({day}, {sync})",
2632
2639
  "ui.conflict.removed.theirs": "úpravy v jiné kopii aplikace",
2633
2640
  "ui.conflict.removing.ours": "pozdější úpravy v jiné kopii aplikace",
@@ -809,6 +809,12 @@
809
809
  "places.identify.why": "Die Person hat darum gebeten: welcher Ort des Namens es ist, wo er liegt, seine Namen und Zugehörigkeiten – für die Karte, die Geschichte und die Häuser.",
810
810
  "places.identify.done": "Jeder Ort der Warteschlange bestimmt, als Frage gestellt oder als nicht auffindbar vermerkt.",
811
811
  "places.identify.result": "jeder Ort der Warteschlange bestimmt, als Frage gestellt oder als nicht auffindbar vermerkt",
812
+ "places.count.identified": "zugeordnet {n}",
813
+ "places.count.verified": "bestätigt {n} (schon zugeordnet, geprüft)",
814
+ "places.count.undecided": "geprüft, nicht entschieden {n}",
815
+ "places.count.asked": "mit Frage {n}",
816
+ "places.count.unlocated": "nicht gefunden {n}",
817
+ "places.count.merged": "zusammengeführt {n}",
812
818
  "history.region.what": "Ereignisse der Geschichte in {region}: Kriege, Epidemien, Volkszählungen, Wechsel des Staates, Auswanderung",
813
819
  "history.region.where": "Enzyklopädien, staatliche Institutionen, Archive, die Wikipedia in der Sprache der Region mit ihren Belegen",
814
820
  "history.region.why": "was die Vorfahren erlebten, bei ihren Lebensläufen gezeigt",
@@ -2628,6 +2634,7 @@
2628
2634
  "ui.conflict.titleAfter": "Titel nach dem Namen",
2629
2635
  "ui.conflict.parents": "Eltern",
2630
2636
  "ui.conflict.removed": "im Stammbaum",
2637
+ "ui.conflict.title": "{who}: {what} – {ours} × {theirs}",
2631
2638
  "ui.conflict.removed.ours": "in der Strom-App entfernt ({day}, {sync})",
2632
2639
  "ui.conflict.removed.theirs": "in einer anderen Kopie der App bearbeitet",
2633
2640
  "ui.conflict.removing.ours": "später in einer anderen Kopie der App bearbeitet",
@@ -809,6 +809,12 @@
809
809
  "places.identify.why": "Osoba o to poprosiła: która miejscowość o tej nazwie to jest, gdzie leży, jej nazwy i przynależność administracyjna – dla mapy, historii i domów.",
810
810
  "places.identify.done": "Każde miejsce z kolejki zidentyfikowane, z pytaniem albo nieodnalezione.",
811
811
  "places.identify.result": "każde miejsce z kolejki zidentyfikowane, z pytaniem albo nieodnalezione",
812
+ "places.count.identified": "zidentyfikowano {n}",
813
+ "places.count.verified": "potwierdzono {n} (już zidentyfikowane, sprawdzone)",
814
+ "places.count.undecided": "sprawdzono, nierozstrzygnięte {n}",
815
+ "places.count.asked": "z pytaniem {n}",
816
+ "places.count.unlocated": "nie odnaleziono {n}",
817
+ "places.count.merged": "scalono {n}",
812
818
  "history.region.what": "Wydarzenia historyczne w regionie {region}: wojny, epidemie, spisy ludności, zmiany państwa, emigracja",
813
819
  "history.region.where": "encyklopedie, instytucje państwowe, archiwa, Wikipedia w języku regionu wraz z jej przypisami",
814
820
  "history.region.why": "co przeżyli przodkowie, pokazane przy ich życiorysach",
@@ -2628,6 +2634,7 @@
2628
2634
  "ui.conflict.titleAfter": "tytuł po nazwisku",
2629
2635
  "ui.conflict.parents": "rodzice",
2630
2636
  "ui.conflict.removed": "w drzewie",
2637
+ "ui.conflict.title": "{who}: {what} – {ours} × {theirs}",
2631
2638
  "ui.conflict.removed.ours": "usunięcie w aplikacji Strom ({day}, {sync})",
2632
2639
  "ui.conflict.removed.theirs": "zmiany w innej kopii aplikacji",
2633
2640
  "ui.conflict.removing.ours": "późniejsze zmiany w innej kopii aplikacji",
@@ -6,5 +6,5 @@
6
6
  "node",
7
7
  "gate.ts"
8
8
  ],
9
- "timeout": 120
9
+ "timeout": 660
10
10
  }
@@ -35,6 +35,13 @@
35
35
  // limits in this run (STROM_AGENT_LIMITS, at most 15 minutes old) is read
36
36
  // instead of asking `claude -p /usage`.
37
37
  //
38
+ // What `claude -p /usage` says is not always read: an answer without the
39
+ // week's line, an exit status, no answer in time. It asks again (ATTEMPTS, a
40
+ // pause between), and then waits a while (UNREAD_WAIT) rather than stopping
41
+ // the run for good — what it got is in its reason (the first line, shortened).
42
+ // A Claude Code not logged in (`claude auth status`; its /usage is then a bare
43
+ // /cost) stops the run, saying how to log in.
44
+ //
38
45
  // strom writes this file; a copy of your own goes into a folder of its own name.
39
46
 
40
47
  import fs from "node:fs";
@@ -65,6 +72,18 @@ const RESERVE = 2;
65
72
  /** The week it waits in for the reserve, in the gate's own folder — only when strom asks it (STROM_GATE). */
66
73
  const HOLD = process.env.STROM_GATE ? "hold.json" : undefined;
67
74
  const lang = (process.env.STROM_LANG ?? "en").slice(0, 2);
75
+ /**
76
+ * How many times `claude -p /usage` is asked before the gate waits; each asking may take ASK_MS — it reads the
77
+ * computer's own sessions for its advice too, which took over a minute on a busy computer (2026-11-03: 75 s).
78
+ */
79
+ const ATTEMPTS = 3;
80
+ const ASK_MS = 180_000;
81
+ /** The pause between two askings (tests give a shorter one: CLAUDE_USAGE_RETRY_MS). */
82
+ const RETRY_MS = /^\d+$/.test(process.env.CLAUDE_USAGE_RETRY_MS ?? "") ? Number(process.env.CLAUDE_USAGE_RETRY_MS) : 30_000;
83
+ /** All the askings within this, inside the gate's timeout (gate.json: 660 s). */
84
+ const BUDGET_MS = 600_000;
85
+ /** Unread after every asking: asked again this much later — a run is not stopped for one answer it could not read. */
86
+ const UNREAD_WAIT = 15 * 60_000;
68
87
 
69
88
  const T: Record<string, Record<string, string>> = {
70
89
  en: {
@@ -84,8 +103,15 @@ const T: Record<string, Record<string, string>> = {
84
103
  badcap: "the cap must be 1 to 100 (\"--cap 95\"), not \"{arg}\"",
85
104
  cap: "week {used} % used — the cap of {cap} % reached; it resets {resets}",
86
105
  unread: "cannot read `claude -p /usage`: {detail}",
106
+ unreadWait: "cannot read `claude -p /usage` ({tries}×): {detail} — asked again in {min} minutes",
87
107
  noweek: "no line \"Current week … resets …\"",
108
+ said: "{detail}; it said: \"{line}\"",
109
+ nothing: "{detail}; it said nothing",
110
+ unused: "week 0 % used — nothing used yet",
88
111
  exit: "it ended with exit status {status}",
112
+ timeout: "no answer in {s} s",
113
+ login: "Claude Code is not logged in — to log in once: claude, then /login",
114
+ loginDir: "Claude Code is not logged in with CLAUDE_CONFIG_DIR={dir} — to log in once with the same variable (CLAUDE_CONFIG_DIR={dir} claude, then /login), or to start strom without it",
89
115
  },
90
116
  cs: {
91
117
  other: "agent není Claude Code — není co hlídat",
@@ -104,8 +130,15 @@ const T: Record<string, Record<string, string>> = {
104
130
  badcap: "strop musí být 1 až 100 (\"--cap 95\"), ne \"{arg}\"",
105
131
  cap: "týden: využito {used} % – strop {cap} % dosažen; obnoví se {resets}",
106
132
  unread: "nelze přečíst `claude -p /usage`: {detail}",
133
+ unreadWait: "nelze přečíst `claude -p /usage` ({tries}×): {detail} — znovu za {min} minut",
107
134
  noweek: "chybí řádek „Current week … resets …“",
135
+ said: "{detail}; vypsalo: „{line}“",
136
+ nothing: "{detail}; nevypsalo nic",
137
+ unused: "týden: využito 0 % — zatím nic",
108
138
  exit: "skončilo s kódem {status}",
139
+ timeout: "bez odpovědi do {s} s",
140
+ login: "Claude Code není přihlášený — přihlásit jednou: claude, pak /login",
141
+ loginDir: "Claude Code není přihlášený s CLAUDE_CONFIG_DIR={dir} — přihlásit jednou se stejnou proměnnou (CLAUDE_CONFIG_DIR={dir} claude, pak /login), nebo spustit strom bez ní",
109
142
  },
110
143
  de: {
111
144
  other: "der Agent ist nicht Claude Code — nichts zu prüfen",
@@ -124,8 +157,42 @@ const T: Record<string, Record<string, string>> = {
124
157
  badcap: "die Obergrenze muss 1 bis 100 sein (\"--cap 95\"), nicht \"{arg}\"",
125
158
  cap: "Woche: {used} % genutzt – Obergrenze {cap} % erreicht; zurückgesetzt {resets}",
126
159
  unread: "`claude -p /usage` nicht lesbar: {detail}",
160
+ unreadWait: "`claude -p /usage` nicht lesbar ({tries}×): {detail} — erneut in {min} Minuten",
127
161
  noweek: "keine Zeile „Current week … resets …“",
162
+ said: "{detail}; Ausgabe: „{line}“",
163
+ nothing: "{detail}; keine Ausgabe",
164
+ unused: "Woche: 0 % genutzt — noch nichts",
128
165
  exit: "mit Exit-Status {status} beendet",
166
+ timeout: "keine Antwort in {s} s",
167
+ login: "Claude Code ist nicht angemeldet — einmal anmelden: claude, dann /login",
168
+ loginDir: "Claude Code ist mit CLAUDE_CONFIG_DIR={dir} nicht angemeldet — einmal mit derselben Variable anmelden (CLAUDE_CONFIG_DIR={dir} claude, dann /login) oder strom ohne sie starten",
169
+ },
170
+ pl: {
171
+ other: "agent to nie Claude Code — nie ma czego pilnować",
172
+ api: "Claude Code działa na kluczu API, nie w subskrypcji — bez limitów planu",
173
+ head: "tydzień: wykorzystano {used} %, upłynęło {gone} %, zapas {behind} (potrzeba co najmniej {min}{early})",
174
+ early: " – na początku tygodnia połowa tego, co upłynęło",
175
+ resetIn: "; tydzień odnowi się za {reset}",
176
+ hold: " — czeka na zapas {target}, potem kilka sesji z rzędu; tydzień odnowi się za {reset}",
177
+ last: " — do ostatnich {window} przed odnowieniem tygodnia za {reset}",
178
+ reset: " — do odnowienia tygodnia za {reset}",
179
+ end: "tydzień: wykorzystano {used} %, odnowi się za {reset} — pozostałe {room} % wystarczy na sesję",
180
+ session: "sesja wyczerpana — do jej odnowienia; tydzień odnowi się za {reset}",
181
+ hours: "{n} godz.",
182
+ days: "{n} dni",
183
+ bad: "liczba musi wynosić od 0 do 99, nie \"{arg}\"",
184
+ badcap: "limit musi wynosić od 1 do 100 (\"--cap 95\"), nie \"{arg}\"",
185
+ cap: "tydzień: wykorzystano {used} % – osiągnięto limit {cap} %; odnowi się {resets}",
186
+ unread: "nie można odczytać `claude -p /usage`: {detail}",
187
+ unreadWait: "nie można odczytać `claude -p /usage` ({tries}×): {detail} — ponownie za {min} minut",
188
+ noweek: "brak wiersza „Current week … resets …”",
189
+ said: "{detail}; wypisało: „{line}”",
190
+ nothing: "{detail}; nic nie wypisało",
191
+ unused: "tydzień: wykorzystano 0 % — jeszcze nic",
192
+ exit: "zakończyło się kodem {status}",
193
+ timeout: "brak odpowiedzi w ciągu {s} s",
194
+ login: "Claude Code nie jest zalogowany — zalogować się raz: claude, potem /login",
195
+ loginDir: "Claude Code nie jest zalogowany z CLAUDE_CONFIG_DIR={dir} — zalogować się raz z tą samą zmienną (CLAUDE_CONFIG_DIR={dir} claude, potem /login) albo uruchomić strom bez niej",
129
196
  },
130
197
  };
131
198
  function say(key: string, v: Record<string, string | number> = {}): string {
@@ -167,26 +234,39 @@ let week: Use | undefined;
167
234
  const said = fromAgent(process.env.STROM_AGENT_LIMITS);
168
235
  if (said) ({ session, week } = said);
169
236
  else {
170
- // ── what Claude Code says ─────────────────────────────────────────────────
171
- const r = spawnSync("claude", ["-p", "/usage"], { encoding: "utf8", timeout: 90_000, shell: process.platform === "win32", windowsHide: true });
172
- // what Claude Code or the system said is said as it is; the gate's own words in the research language (STROM_LANG)
173
- if (r.error || r.status !== 0) answer(2, say("unread", { detail: r.error?.message ?? ((r.stderr || r.stdout || "").trim().split("\n")[0] || say("exit", { status: String(r.status) })) }));
174
- const text = r.stdout;
175
- if (/API key/i.test(text) && !/Current week/i.test(text)) {
176
- cap = undefined;
177
- answer(0, say("api"));
178
- }
179
- const LINE = /^Current (session|week)(?: \(([^)]+)\))?:\s*(\d+)%\s*used(?:\s*·\s*resets\s*(.+))?$/i;
180
- for (const raw of text.split(/\r?\n/)) {
181
- const m = LINE.exec(raw.trim());
182
- if (!m) continue;
183
- const item = { used: Number(m[3]), ...(m[4] ? { resets: resetAt(m[4]) } : {}) };
184
- if (m[1]!.toLowerCase() === "session") session = item;
185
- // the limit of all models is the one that stops the work; a model's own week only if there is no other
186
- else if (!week || /all models/i.test(m[2] ?? "")) week = item;
237
+ // ── what Claude Code says: asked again when it cannot be read, then a wait — never a stop for one answer ─────
238
+ const began = Date.now();
239
+ let detail = "";
240
+ let tries = 0;
241
+ for (;;) {
242
+ tries++;
243
+ const read = askUsage(Math.min(ASK_MS, BUDGET_MS - (Date.now() - began)));
244
+ if ("api" in read) {
245
+ cap = undefined;
246
+ answer(0, say("api"));
247
+ }
248
+ if ("week" in read) {
249
+ ({ session, week } = read);
250
+ break;
251
+ }
252
+ detail = read.detail;
253
+ // not logged in (its /usage a bare /cost): no asking again makes it read — said with how to log in
254
+ if (tries === 1 && loggedIn() === false) {
255
+ cap = undefined;
256
+ const dir = process.env.CLAUDE_CONFIG_DIR?.trim();
257
+ answer(2, dir ? say("loginDir", { dir }) : say("login"));
258
+ }
259
+ if (tries >= ATTEMPTS || BUDGET_MS - (Date.now() - began) < RETRY_MS + ASK_MS / 2) break;
260
+ sleep(RETRY_MS);
187
261
  }
262
+ if (!week) answer(1, say("unreadWait", { tries, detail, min: UNREAD_WAIT / 60_000 }), new Date(Date.now() + UNREAD_WAIT));
263
+ }
264
+ if (!week) answer(2, say("unread", { detail: say("noweek") }));
265
+ // the week's line without its reset: nothing used yet goes (a new week), else what it says is not read
266
+ if (!week.resets) {
267
+ if (week.used === 0) answer(0, say("unused"));
268
+ answer(1, say("unreadWait", { tries: 1, detail: say("noweek"), min: UNREAD_WAIT / 60_000 }), new Date(Date.now() + UNREAD_WAIT));
188
269
  }
189
- if (!week?.resets) answer(2, say("unread", { detail: say("noweek") }));
190
270
  // the cap first: the user's own hard stop, whatever the pace
191
271
  if (cap !== undefined && week.used >= cap)
192
272
  answer(2, say("cap", { used: Math.floor(week.used), cap, resets: week.resets.toLocaleString(lang, { weekday: "short", day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" }) }), undefined, true);
@@ -231,6 +311,59 @@ const tail =
231
311
  at === caught ? say("hold", { reset, target: Math.ceil(need + reserve) }) : at === last ? say("last", { reset, window: say("hours", { n: END_WINDOW / 3_600_000 }) }) : say("reset", { reset });
232
312
  answer(1, say("head", { ...v, min: Math.ceil(need) }) + tail, new Date(Math.max(at, now + 5 * 60_000)));
233
313
 
314
+ /**
315
+ * `claude -p /usage` once: the session's and the week's use, an API key's answer, or why it was not read — with the
316
+ * first line it said (shortened, no e-mail address), so the gate's log says what Claude Code answered.
317
+ */
318
+ function askUsage(timeout: number): { session?: Use; week: Use } | { api: true } | { detail: string } {
319
+ const r = spawnSync("claude", ["-p", "/usage"], { encoding: "utf8", timeout: Math.max(5_000, timeout), shell: process.platform === "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
320
+ // what Claude Code or the system said is said as it is; the gate's own words in the research language (STROM_LANG)
321
+ const out = `${r.stdout ?? ""}\n${r.stderr ?? ""}`;
322
+ const line = firstLine(r.status === 0 ? (r.stdout ?? "") : out);
323
+ const why = (detail: string) => ({ detail: line ? say("said", { detail, line }) : say("nothing", { detail }) });
324
+ if (r.error) return (r.error as NodeJS.ErrnoException).code === "ETIMEDOUT" ? why(say("timeout", { s: Math.round(timeout / 1000) })) : { detail: r.error.message };
325
+ if (r.status !== 0) return why(say("exit", { status: String(r.status) }));
326
+ const text = r.stdout ?? "";
327
+ if (/API key/i.test(text) && !/Current week/i.test(text)) return { api: true };
328
+ let session: Use | undefined;
329
+ let week: Use | undefined;
330
+ const LINE = /^Current (session|week)(?: \(([^)]+)\))?:\s*(\d+(?:\.\d+)?)\s*%\s*used(?:\s*·\s*resets\s*(.+))?$/i;
331
+ for (const raw of text.split(/\r?\n/)) {
332
+ const m = LINE.exec(raw.trim());
333
+ if (!m) continue;
334
+ const at = m[4] ? resetAt(m[4]) : undefined;
335
+ const item = { used: Number(m[3]), ...(at ? { resets: at } : {}) };
336
+ if (m[1]!.toLowerCase() === "session") session = item;
337
+ // the limit of all models is the one that stops the work; a model's own week only if there is no other
338
+ else if (!week || /all models/i.test(m[2] ?? "")) week = item;
339
+ }
340
+ if (!week || (!week.resets && week.used !== 0)) return why(say("noweek"));
341
+ return { week, ...(session ? { session } : {}) };
342
+ }
343
+
344
+ /** The first line said that is not empty, shortened, without an e-mail address (an account's name stays out of the log). */
345
+ function firstLine(text: string): string {
346
+ const line = text.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
347
+ const clean = line.replace(/[\p{L}\p{N}._%+-]+@[\p{L}\p{N}.-]+/gu, "…@…");
348
+ return clean.length > 80 ? `${clean.slice(0, 79)}…` : clean;
349
+ }
350
+
351
+ /** `claude auth status` says whether it is logged in; undefined when it cannot tell (an older Claude Code). */
352
+ function loggedIn(): boolean | undefined {
353
+ try {
354
+ const r = spawnSync("claude", ["auth", "status"], { encoding: "utf8", timeout: 20_000, shell: process.platform === "win32", windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
355
+ const said = JSON.parse((r.stdout ?? "").trim()) as { loggedIn?: unknown };
356
+ return typeof said?.loggedIn === "boolean" ? said.loggedIn : undefined;
357
+ } catch {
358
+ return undefined;
359
+ }
360
+ }
361
+
362
+ /** Waits here (the gate is one short program: nothing else to do meanwhile). */
363
+ function sleep(ms: number): void {
364
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
365
+ }
366
+
234
367
  /** It waits for the reserve in this week (the week it remembers ends within a day of this one's reset). */
235
368
  function holding(resets: number): boolean {
236
369
  if (!HOLD) return false;
@@ -287,16 +420,33 @@ function fromAgent(json: string | undefined): { session?: Use; week: Use } | und
287
420
  return { week, ...(session ? { session } : {}) };
288
421
  }
289
422
 
290
- /** "Sep 30 at 2pm (Europe/Prague)", "Oct 2 at 11:30am (UTC)" → the moment. */
423
+ /**
424
+ * "Sep 30 at 2pm (Europe/Prague)", "Oct 2 at 11:30am (UTC)", "Nov 4 at 13:00 (Europe/Prague)", "Nov 4, 1pm (UTC)" → the
425
+ * moment; a time alone ("6pm (Europe/Prague)", a session's) → its next coming.
426
+ */
291
427
  function resetAt(s: string): Date | undefined {
292
- const m = /^(\w{3})\w*\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i.exec(s.trim());
428
+ const m = /^(?:([A-Za-z]{3})[A-Za-z]*\.?\s+(\d{1,2})(?:\s+at|,)?\s+)?(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*([ap]\.?m\.?)?\s*\(([^)]+)\)/i.exec(s.trim());
293
429
  if (!m) return undefined;
294
- const month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].indexOf(m[1]!.toLowerCase());
295
- if (month < 0) return undefined;
296
- const hour = (Number(m[3]) % 12) + (m[5]!.toLowerCase() === "pm" ? 12 : 0);
430
+ const ampm = m[5]?.replace(/\./g, "").toLowerCase();
431
+ const h = Number(m[3]);
432
+ if (ampm ? h < 1 || h > 12 : h > 23) return undefined;
433
+ const hour = ampm ? (h % 12) + (ampm === "pm" ? 12 : 0) : h;
434
+ const minute = Number(m[4] ?? 0);
297
435
  const zone = m[6]!;
436
+ if (!m[1]) {
437
+ // a time alone: today's in the zone, else tomorrow's
438
+ const [y, mo, d] = new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date()).split("-").map(Number) as [number, number, number];
439
+ for (const add of [0, 1]) {
440
+ const guess = Date.UTC(y, mo - 1, d + add, hour, minute);
441
+ const at = guess - offsetMs(guess, zone);
442
+ if (at > Date.now()) return new Date(at);
443
+ }
444
+ return undefined;
445
+ }
446
+ const month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].indexOf(m[1].toLowerCase());
447
+ if (month < 0) return undefined;
298
448
  for (const year of [new Date().getUTCFullYear(), new Date().getUTCFullYear() + 1]) {
299
- const guess = Date.UTC(year, month, Number(m[2]), hour, Number(m[4] ?? 0));
449
+ const guess = Date.UTC(year, month, Number(m[2]), hour, minute);
300
450
  const at = guess - offsetMs(guess, zone);
301
451
  if (at > Date.now() - DAY) return new Date(at);
302
452
  }