strom-research 1.26.0-beta.7 → 1.26.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.
package/CHANGELOG.cs.md CHANGED
@@ -7,7 +7,7 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
7
7
 
8
8
  ## 1.26.0
9
9
 
10
- *Připravuje se: bety do 1.26.0-beta.7. Oddíl bude úplný s vydáním.*
10
+ *4. 11. 2026 · patří k aplikaci Strom 3.22.0.*
11
11
 
12
12
  - **Linie výzkumu z aplikace Strom: založit, upravit, pozastavit, seřadit.** Linii jde v aplikaci založit nebo upravit
13
13
  (osoba, směr, otázka, meze), pozastavit, ukončit nebo znovu spustit; pořadí linií je pořadí práce a stránka telefonu
@@ -28,6 +28,14 @@ Změny každé verze ve zkratce. Úplné poznámky a instalační řádky jsou n
28
28
  - Velký rodokmen převzatý ve Windows (zhruba 1 500 osob a víc) už neskončí chybou gitu o příliš dlouhém příkazu
29
29
  (ENAMETOOLONG) bez jediného zapsaného údaje: vše převzaté se zapíše.
30
30
 
31
+ - Na velkém výzkumu (stovky úkolů ve frontě, hodně věcí k vyřízení) už aplikace Strom neříká, že výzkum neodpovídá,
32
+ když jeho agent pracuje: spojení s aplikací spočítá stav výzkumu jednou pro každou změnu, stranou, a aplikaci mezitím
33
+ odpovídá hned – i během denního úklidu, hledání oprav strom, čtení historie po každé změně a přípravy přehledu pro
34
+ widget na ploše.
35
+
36
+ - Vyprávění i poznámka přijdou do aplikace Strom s mezerami, jak jsou napsané: dvě mezery mezi slovy, nezlomitelná
37
+ ani úzká mezera už se nemění na jednu obyčejnou.
38
+
31
39
  - **Co rozhoduje uživatel, zůstává jeho.** Rozpor výzkumu s úpravou z aplikace Strom, schválení vyprávění nebo jeho nové
32
40
  verze a odpověď u větve, která roste, rozhoduje jen uživatel – v aplikaci nebo v Co čeká; agenta strom odmítne a ten
33
41
  jen řekne, že to čeká. Vzít zpět, co aplikace poslala nebo co se synchronizovalo (`strom sync undo`), a zahodit strom,
package/CHANGELOG.de.md CHANGED
@@ -7,7 +7,7 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
7
7
 
8
8
  ## 1.26.0
9
9
 
10
- *In Vorbereitung: die Betas bis 1.26.0-beta.7. Der Abschnitt ist mit dem Release vollständig.*
10
+ *4.11.2026 · gehört zur Strom-App 3.22.0.*
11
11
 
12
12
  - **Die Linien der Forschung aus der Strom-App: anlegen, ändern, pausieren, ordnen.** Eine Linie lässt sich in der App
13
13
  anlegen oder ändern (Person, Richtung, Frage, Grenzen), pausieren, beenden oder wieder aufnehmen; die Reihenfolge der
@@ -31,6 +31,15 @@ Die Änderungen jeder Version in Kürze. Die vollständigen Hinweise und die Ins
31
31
  - Ein großer Stammbaum, unter Windows übernommen (etwa 1 500 Personen und mehr), scheitert nicht mehr in git an einer zu
32
32
  langen Befehlszeile (ENAMETOOLONG), ohne dass etwas davon bleibt: alles Übernommene wird geschrieben.
33
33
 
34
+ - Bei einer großen Forschung (Hunderte Aufgaben in der Warteschlange, vieles wartet) meldet die Strom-App nicht mehr,
35
+ dass die Forschung nicht antwortet, während ihr Agent arbeitet: die Verbindung berechnet den Stand der Forschung einmal
36
+ je Änderung, nebenher, und antwortet der App inzwischen sofort – auch während des täglichen Aufräumens, der Suche nach
37
+ den Korrekturen von strom, dem Lesen der Historie nach jeder Änderung und der Übersicht für das Widget auf dem
38
+ Schreibtisch.
39
+
40
+ - Eine Erzählung und eine Notiz kommen mit ihren Leerzeichen in die Strom-App, wie sie geschrieben sind: zwei
41
+ Leerzeichen zwischen Wörtern, ein geschütztes oder schmales Leerzeichen werden nicht mehr zu einem einfachen.
42
+
34
43
  - **Was der Nutzer entscheidet, bleibt seine Sache.** Einen Widerspruch zwischen der Forschung und einer Änderung in der
35
44
  Strom-App, die Freigabe einer Erzählung oder ihrer neuen Fassung und die Antwort zu einem wachsenden Zweig entscheidet
36
45
  allein der Nutzer – in der App oder unter Was wartet; der Agent wird abgewiesen und sagt nur, dass es wartet. Auch was
package/CHANGELOG.md CHANGED
@@ -7,7 +7,7 @@ The changes of each version in short. The full notes and the install lines are o
7
7
 
8
8
  ## 1.26.0
9
9
 
10
- *In preparation: the betas up to 1.26.0-beta.7. The section is complete with the release.*
10
+ *2026-11-04 · goes with the Strom app 3.22.0.*
11
11
 
12
12
  - **The lines of the research from the Strom app: created, changed, paused, put in order.** A line is created or changed
13
13
  in the app (its person, direction, question, limits), paused, ended or taken up again; the order of the lines is the
@@ -29,6 +29,14 @@ The changes of each version in short. The full notes and the install lines are o
29
29
  - A big family tree taken in on Windows (some 1 500 people and more) no longer fails in git over a command line too long
30
30
  (ENAMETOOLONG) with nothing of it kept: everything taken in is written.
31
31
 
32
+ - On a large research (hundreds of tasks in the queue, much waiting) the Strom app no longer says the research does
33
+ not answer while its agent works: the connection works out the state of the research once for each change, on its
34
+ own, and answers the app at once meanwhile – also while it keeps its order once a day, finds strom's fixes, reads
35
+ the history after each change and makes the overview for the desktop widget.
36
+
37
+ - A story or a note comes to the Strom app with its spaces as written: two between words, a no-break or a narrow
38
+ space are no longer made one plain space.
39
+
32
40
  - **What is the user's to decide stays the user's.** A disagreement between the research and an edit made in the Strom
33
41
  app, the approval of a story or of its new version, and the answer for a branch that grows are decided by the user
34
42
  alone – in the app or in What waits; the agent is refused and only says that it waits. Taking back what the app sent
package/CHANGELOG.pl.md CHANGED
@@ -7,7 +7,7 @@ Zmiany każdej wersji w skrócie. Pełne informacje o wydaniach i polecenia inst
7
7
 
8
8
  ## 1.26.0
9
9
 
10
- *W przygotowaniu: bety do 1.26.0-beta.7. Sekcja będzie kompletna z wydaniem.*
10
+ *4.11.2026 · współpracuje z aplikacją Strom 3.22.0.*
11
11
 
12
12
  - **Linie badania z aplikacji Strom: założyć, zmienić, wstrzymać, uporządkować.** Linię można w aplikacji założyć lub
13
13
  zmienić (osoba, kierunek, pytanie, granice), wstrzymać, zakończyć lub wznowić; kolejność linii to kolejność pracy, a
@@ -29,6 +29,14 @@ Zmiany każdej wersji w skrócie. Pełne informacje o wydaniach i polecenia inst
29
29
  - Duże drzewo przejęte w systemie Windows (około 1500 osób i więcej) nie kończy się już błędem gita o zbyt długim
30
30
  poleceniu (ENAMETOOLONG) bez żadnej zapisanej danej: wszystko, co przejęte, zostaje zapisane.
31
31
 
32
+ - Przy dużym badaniu (setki zadań w kolejce, wiele spraw czekających) aplikacja Strom nie zgłasza już, że badanie nie
33
+ odpowiada, gdy pracuje jego agent: połączenie oblicza stan badania raz dla każdej zmiany, osobno, a aplikacji
34
+ odpowiada w tym czasie od razu – także podczas codziennego porządkowania, szukania poprawek strom, czytania historii
35
+ po każdej zmianie i przygotowania przeglądu dla widżetu na pulpicie.
36
+
37
+ - Opowieść i notatka trafiają do aplikacji Strom ze spacjami takimi, jak zostały zapisane: dwie spacje między
38
+ słowami ani spacja nierozdzielająca lub wąska nie zamieniają się już w jedną zwykłą.
39
+
32
40
  - **To, o czym decyduje użytkownik, pozostaje jego decyzją.** O sprzeczności badania ze zmianą z aplikacji Strom,
33
41
  o zatwierdzeniu opowieści lub jej nowej wersji i o odpowiedzi dla rosnącej gałęzi decyduje tylko użytkownik – w
34
42
  aplikacji albo w Co czeka; agent zostaje odrzucony i mówi tylko, że to czeka. Cofnięcie tego, co wysłała aplikacja lub
package/dist/core/live.js CHANGED
@@ -9,6 +9,12 @@
9
9
  //
10
10
  // GET <token>/status the tree, its researches, who is at work, what waits; what the research takes from
11
11
  // the app (accepts). ?poll=1: the app asking now and then — the bridge's idle time goes on.
12
+ // Made once for a state of the research on a thread of its own (core/livestatus.ts) and
13
+ // answered from it while the state lasts. ?parts=1 (feature status.parts): without the parts
14
+ // that weigh (waiting; queue, queueMore, queueCounts; offLine; researches), with parts {name:
15
+ // rev} instead — the same rev, the same part.
16
+ // GET <token>/status/<part> one of those parts: {part, rev, head, …its fields as /status carries them}; 404
17
+ // status.part for another name.
12
18
  // A queue item off the lines of a research with several (core/offline.ts) says offLine:
13
19
  // "occasional" (now and then one), "last" (after the lines' work) or "never" (not taken) — its
14
20
  // branch's choice, else queue.offLine; one that helps a line says how (helps: question | joins |
@@ -292,6 +298,8 @@ import os from "node:os";
292
298
  import http from "node:http";
293
299
  import path from "node:path";
294
300
  import crypto from "node:crypto";
301
+ import { fileURLToPath } from "node:url";
302
+ import { Worker } from "node:worker_threads";
295
303
  import { spawn, spawnSync } from "node:child_process";
296
304
  import { configDir, userHome } from "./paths.js";
297
305
  import { newerTree, now, SEAL_FOREIGN, sealedHere, Tree, TREE_FILE, VERSION } from "./tree.js";
@@ -307,7 +315,8 @@ import { foundByLine } from "./found.js";
307
315
  import { orderMode, orderNumbers, workOrder } from "./order.js";
308
316
  import { phrase } from "./phrases.js";
309
317
  import { excerptLevel, excerptSizes, INPUT_MARK, LEVELS, turned } from "./excerpt.js";
310
- import { endWork, excerptAnswer, excerptOnThread, gedOnThread } from "./liveged.js";
318
+ import { endWork, excerptAnswer, excerptOnThread, fixesOnThread, gedOnThread, widgetOnThread } from "./liveged.js";
319
+ import { isStatusPart, splitStatus, StatusKeeper, STATUS_PARTS } from "./livestatus.js";
311
320
  import { adoptedAt, adoptedEmpty, adoptionWait, noteAdoptAsked, markSentAgain, undoneSend, undoneSince, failReceived, inboxTrees, markAdopted, noteAdoptFailed, noteNothingSent, pendingAdoption, receiveAdopted, receivedAll, receivedPending, receiveTree, recentSends, stampAppVersion, syncConflicts, SYNC_INBOX, SYNC_MAX_BYTES, namesOf } from "./sync.js";
312
321
  import { isArchive, modeOf, settleArchive } from "./mode.js";
313
322
  import { actsAsArchive, kindOf, stateOf } from "./state.js";
@@ -1093,7 +1102,7 @@ function idsOf(applied, known) {
1093
1102
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
1094
1103
  }
1095
1104
  /** What the bridge does that an app may ask about (each added once, never taken away). */
1096
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname", "phone", "read.now", "read.sync", "excerpt.get", "log.sources", "material.assign", "material.thumb", "seal.foreign", "tree.state", "disk.full", "tree.notDownloaded", "sync.removed", "tree.kind", "experiments", "experiments.off", "experiments.link", "experiments.split", "history.events", "history.hide", "experiments.pace", "history.first", "history.regions", "history.hide.all", "history.skip", "history.map", "history.delete", "history.delete.restore", "stories.history", "history.place", "surnames.suggest", "surnames.read", "lineage.read", "duplicates.judge", "quality.dismiss", "quality.read", "person.primary", "fixes.list", "fixes.towns", "history.back", "places.identify", "places.answer", "places.undo", "tasks.offLine", "status.busy", "history.kinds", "history.align", "places.nameKinds", "directions.edit", "directions.order", "directions.found", "directions.people", "offLine.branches", "queue.own", "story.sources", "queue.page"];
1105
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes", "hypothesis.links", "status.recent", "spend.readers", "hypothesis.intree", "archive.permission", "task.check", "waiting.groups", "waiting.sent", "waiting.note", "batch.kept", "live.end", "whats.new", "terminal.choice", "person.marriedSurname", "phone", "read.now", "read.sync", "excerpt.get", "log.sources", "material.assign", "material.thumb", "seal.foreign", "tree.state", "disk.full", "tree.notDownloaded", "sync.removed", "tree.kind", "experiments", "experiments.off", "experiments.link", "experiments.split", "history.events", "history.hide", "experiments.pace", "history.first", "history.regions", "history.hide.all", "history.skip", "history.map", "history.delete", "history.delete.restore", "stories.history", "history.place", "surnames.suggest", "surnames.read", "lineage.read", "duplicates.judge", "quality.dismiss", "quality.read", "person.primary", "fixes.list", "fixes.towns", "history.back", "places.identify", "places.answer", "places.undo", "tasks.offLine", "status.busy", "history.kinds", "history.align", "places.nameKinds", "directions.edit", "directions.order", "directions.found", "directions.people", "offLine.branches", "queue.own", "story.sources", "queue.page", "status.parts"];
1097
1106
  /** /status busy: how many sessions and runs work now. */
1098
1107
  function busyOf(tree) {
1099
1108
  const b = busyIn(tree);
@@ -1179,6 +1188,52 @@ function returnOf(ops) {
1179
1188
  export function history(root, tree, range = [`-n${LOG_MAX}`]) {
1180
1189
  return rendered(tree, rawCommits(root, range));
1181
1190
  }
1191
+ /**
1192
+ * What the bridge works out for a state of the research (/log for a head, /fixes for their stamp), one at a time: an ask
1193
+ * for the state being made waits for it; an ask for another waits for the next, made once for the state as it is when
1194
+ * that one starts. (An agent's commits a second apart each made one more, side by side on a busy computer: each took
1195
+ * longer, and the answers came ten seconds late and later — found 2026-11-04.)
1196
+ */
1197
+ export class OneAtATime {
1198
+ making;
1199
+ next;
1200
+ keyNow;
1201
+ make;
1202
+ constructor(keyNow, make) {
1203
+ this.keyNow = keyNow;
1204
+ this.make = make;
1205
+ }
1206
+ ask(key) {
1207
+ if (!this.making)
1208
+ return this.start(key);
1209
+ if (this.making.key === key)
1210
+ return this.making.done;
1211
+ this.next ??= this.making.done
1212
+ .catch(() => undefined)
1213
+ .then(() => {
1214
+ this.next = undefined;
1215
+ return this.start(this.keyNow());
1216
+ });
1217
+ return this.next;
1218
+ }
1219
+ start(key) {
1220
+ let done;
1221
+ try {
1222
+ done = this.make(key);
1223
+ }
1224
+ catch (e) {
1225
+ done = Promise.reject(e);
1226
+ }
1227
+ const now = { key, done };
1228
+ this.making = now;
1229
+ const over = () => {
1230
+ if (this.making === now)
1231
+ this.making = undefined;
1232
+ };
1233
+ done.then(over, over);
1234
+ return done;
1235
+ }
1236
+ }
1182
1237
  /**
1183
1238
  * The history a bridge gives again and again (/log, each change): what git says of a commit is kept by its hash, so
1184
1239
  * after a new commit git is asked about the new ones only — the same entries, in the same order, as history() gives.
@@ -1193,11 +1248,15 @@ export class HistoryCache {
1193
1248
  }
1194
1249
  /** The last LOG_MAX commits up to `h`, newest first (as history(root, tree) at h). */
1195
1250
  log(tree, h) {
1251
+ return rendered(tree, this.raws(h));
1252
+ }
1253
+ /** What git says of the last LOG_MAX commits up to `h`, newest first: asked about the ones it does not know only. */
1254
+ raws(h) {
1196
1255
  if (!h)
1197
1256
  return [];
1198
1257
  const listed = runGit(this.root, ["rev-list", `-n${LOG_MAX}`, h]);
1199
1258
  if (listed.status !== 0)
1200
- return history(this.root, tree, [`-n${LOG_MAX}`, h]);
1259
+ return rawCommits(this.root, [`-n${LOG_MAX}`, h]);
1201
1260
  const order = listed.stdout.split("\n").filter(Boolean);
1202
1261
  // the newest ones it has not seen; anything else unknown (a history written anew): all of it again
1203
1262
  let k = 0;
@@ -1209,9 +1268,9 @@ export class HistoryCache {
1209
1268
  this.read = k;
1210
1269
  if (fresh.length !== k || fresh.some((c, i) => c.head !== order[i])) {
1211
1270
  this.read = order.length;
1212
- return this.keep(tree, rawCommits(this.root, [`-n${LOG_MAX}`, h]));
1271
+ return this.keep(rawCommits(this.root, [`-n${LOG_MAX}`, h]));
1213
1272
  }
1214
- return this.keep(tree, order.map((x, i) => (i < k ? fresh[i] : this.known.get(x))));
1273
+ return this.keep(order.map((x, i) => (i < k ? fresh[i] : this.known.get(x))));
1215
1274
  }
1216
1275
  /** The commits after `from` up to `to` (a change the app hears), newest first; kept for the next /log. */
1217
1276
  since(tree, range) {
@@ -1226,10 +1285,10 @@ export class HistoryCache {
1226
1285
  }
1227
1286
  return rendered(tree, raws);
1228
1287
  }
1229
- keep(tree, raws) {
1288
+ keep(raws) {
1230
1289
  // oldest first, as since() adds the newer ones after them
1231
1290
  this.known = new Map([...raws].reverse().map((c) => [c.head, c]));
1232
- return rendered(tree, raws);
1291
+ return raws;
1233
1292
  }
1234
1293
  }
1235
1294
  /**
@@ -1735,7 +1794,11 @@ export function saidAppVersion(req) {
1735
1794
  return said && isAppVersion(said) ? said : undefined;
1736
1795
  }
1737
1796
  /** What the app shows beside the tree. */
1738
- function status(root, env, version, seen) {
1797
+ /**
1798
+ * /status worked out (on the bridge's thread of /status, core/livestatus.ts: it reads the whole research — never on the
1799
+ * bridge's own thread, which answers everything else meanwhile).
1800
+ */
1801
+ export function status(root, env, version, seen) {
1739
1802
  // a research a newer strom wrote (a beta's, this strom back on the releases): said, never opened
1740
1803
  const refused = newerTree(root, env);
1741
1804
  if (refused) {
@@ -1856,6 +1919,16 @@ function historyRevNow(tree, settings, h, fresh) {
1856
1919
  historyRevs.set(tree.root, { at: Date.now(), head: h, rev });
1857
1920
  return rev;
1858
1921
  }
1922
+ /** What this thread knows of the history's rev (the bridge's tick, GET /history): told to the thread of /status. */
1923
+ export function historyRevKnown(root) {
1924
+ return historyRevs.get(root);
1925
+ }
1926
+ /** The history's rev the bridge's thread found, taken by the thread of /status when newer than its own. */
1927
+ export function historyRevLearn(root, known) {
1928
+ const had = historyRevs.get(root);
1929
+ if (known && (!had || had.at < known.at))
1930
+ historyRevs.set(root, known);
1931
+ }
1859
1932
  /** A tree the app is told the history of: with the experiment on, or one that has anything of it (its history/ folder). */
1860
1933
  function historyShown(tree, settings) {
1861
1934
  return historyOn(tree, settings) || fs.existsSync(path.join(tree.root, "history"));
@@ -1957,6 +2030,13 @@ export function serveLive(root, env) {
1957
2030
  /** The excerpts the tree's file of that head leaves out, though the research has them (the same for every app): /excerpts. */
1958
2031
  let left;
1959
2032
  let log;
2033
+ // /log worked out for one head at a time (the asks for it wait for it together, those for a newer one for the next)
2034
+ const logs = new OneAtATime(() => head(root), (h) => log?.head === h
2035
+ ? Promise.resolve(log.text)
2036
+ : keeper.log(h).then((text) => {
2037
+ log = { head: h, text };
2038
+ return text;
2039
+ }));
1960
2040
  // what git said of each commit, kept: a new commit costs only itself (/log, the changes heard)
1961
2041
  const histories = new HistoryCache(root);
1962
2042
  /** A request that failed: said to the app, written into the log — the bridge goes on. */
@@ -1993,6 +2073,29 @@ export function serveLive(root, env) {
1993
2073
  const receiver = { root, env, note: (text) => noteLive(root, text), track, head: () => head(root), safely };
1994
2074
  // the documents the app asks to be read (live-read.ts): their queue read one at a time, each by a strom of its own
1995
2075
  const reads = new AppReadings({ root, env, settings: () => new Settings(env, {}), note: (text) => noteLive(root, text), safely });
2076
+ // /status made on a thread of its own, kept for the state it was made for (core/livestatus.ts): the state is the commit
2077
+ // and what changes beside it — the day, who is at work, the tree's settings, this computer's, the choices off the lines
2078
+ const stamp = (file) => {
2079
+ try {
2080
+ return String(fs.statSync(file).mtimeMs);
2081
+ }
2082
+ catch {
2083
+ return "-";
2084
+ }
2085
+ };
2086
+ const keeper = new StatusKeeper({
2087
+ root,
2088
+ env,
2089
+ git: gitProgram() ?? "git",
2090
+ stateNow: () => [now().slice(0, 10), JSON.stringify(liveWorkers(root)), stamp(path.join(root, TREE_FILE)), stamp(configFile(env)), stamp(path.join(root, ...OFFLINE_FILE.split("/"))), historyRevs.get(root)?.rev ?? "", JSON.stringify(agentsHere(env)), diskVersion(env)?.version ?? ""].join("|"),
2091
+ known: () => historyRevKnown(root),
2092
+ inline: (version, key) => splitStatus(status(root, env, version, reads.seen), key),
2093
+ logHere: (h) => JSON.stringify({ entries: histories.log(Tree.open(root, env), h) }),
2094
+ note: (text) => noteLive(root, text),
2095
+ });
2096
+ // the app's version of the last /status asked: made for it again before it asks, when a commit comes
2097
+ // (a bridge that takes over from one of an older strom: the version the app asked that one with)
2098
+ let statusVersion = env.STROM_LIVE_STATUS_FOR || undefined;
1996
2099
  /**
1997
2100
  * What changes something (a send, an adoption, an original, a batch, the app saying nothing came): one line in the
1998
2101
  * log of how it went — its answer's code, marks and why, so the research keeps a trace of each (found on Windows: a
@@ -2176,15 +2279,38 @@ export function serveLive(root, env) {
2176
2279
  // strom's fixes as the app was last told them, while nothing they are found by changed (fixesStamp) and the research
2177
2280
  // is as much of an archive as then
2178
2281
  let fixesKept;
2282
+ const fixesNow = (m) => {
2283
+ const tree = Tree.open(root, env);
2284
+ const settings = new Settings(env, {});
2285
+ const archive = actsAsArchive(tree);
2286
+ return { tree, settings, archive, key: `${m.fixesStamp(tree, m.fixEnvOf(settings, env, tree))}|${archive}` };
2287
+ };
2288
+ let fixes;
2289
+ const fixesOf = (m) => (fixes ??= new OneAtATime(() => fixesNow(m).key, (key) => {
2290
+ if (fixesKept?.key === key)
2291
+ return Promise.resolve(fixesKept.text);
2292
+ // the thread failed (or is turned off): found here, as before
2293
+ const here = () => {
2294
+ const now = fixesNow(m);
2295
+ return { key: now.key, text: JSON.stringify(m.fixesForApp(now.tree, now.settings, env, { archive: now.archive })) };
2296
+ };
2297
+ const found = env.STROM_LIVE_STATUS_THREAD === "0" ? Promise.resolve().then(here) : fixesOnThread({ root, env }).catch(here);
2298
+ return found.then((made) => {
2299
+ // kept for the stamp the research was read at (a commit since: another)
2300
+ fixesKept = made;
2301
+ return made.text;
2302
+ });
2303
+ }));
2179
2304
  const giveFixes = (req, res) => {
2180
2305
  import("../commands/fixchecks.js").then((m) => safely(req, res, () => {
2181
- const tree = Tree.open(root, env);
2182
- const settings = new Settings(env, {});
2183
- const archive = actsAsArchive(tree);
2184
- const key = `${m.fixesStamp(tree, m.fixEnvOf(settings, env, tree))}|${archive}`;
2185
- if (fixesKept?.key !== key)
2186
- fixesKept = { key, text: JSON.stringify(m.fixesForApp(tree, settings, env, { archive })) };
2187
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(fixesKept.text);
2306
+ const { key } = fixesNow(m);
2307
+ const answer = (text) => {
2308
+ if (!req.destroyed)
2309
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
2310
+ };
2311
+ if (fixesKept?.key === key)
2312
+ return answer(fixesKept.text);
2313
+ fixesOf(m).ask(key).then(answer, (e) => failed(req, res, e));
2188
2314
  }), (e) => failed(req, res, e));
2189
2315
  };
2190
2316
  const afterQuiet = (fn) => {
@@ -2244,6 +2370,9 @@ export function serveLive(root, env) {
2244
2370
  }
2245
2371
  const origin = allowedOrigin(req.headers.origin);
2246
2372
  const version = appVersionOf(req, new Settings(env, {}));
2373
+ // what the app sends may change what /status says without a commit (a send waiting, a reading, a batch): made again
2374
+ if (req.method !== "GET" && req.method !== "OPTIONS")
2375
+ res.once("finish", () => keeper.forget());
2247
2376
  if (origin) {
2248
2377
  res.setHeader("Access-Control-Allow-Origin", origin);
2249
2378
  res.setHeader("Vary", "Origin");
@@ -2604,10 +2733,20 @@ export function serveLive(root, env) {
2604
2733
  const got = groupNote(decoded(sub));
2605
2734
  res.writeHead(got.code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(got.body));
2606
2735
  }
2607
- else if (what === "status") {
2608
- // what is answered is made first: a read that fails can still answer 500
2609
- const text = JSON.stringify(status(root, env, version, reads.seen));
2610
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
2736
+ else if (what === "status" && !sub) {
2737
+ // made on its thread, kept for the state of the research (core/livestatus.ts); parts=1: without the parts that
2738
+ // weigh, their revs instead (feature status.parts: the app asks a part only when its rev changed)
2739
+ const lite = new URLSearchParams((req.url ?? "").split("?")[1] ?? "").get("parts") === "1";
2740
+ statusVersion = version;
2741
+ keeper.get(version).then((made) => res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(lite ? made.lite : made.full), (e) => failed(req, res, e));
2742
+ }
2743
+ else if (what === "status" && sub && !act) {
2744
+ // one part of /status that weighs (feature status.parts): {part, rev, head, …its fields as /status carries them}
2745
+ const part = decoded(sub);
2746
+ if (!isStatusPart(part))
2747
+ res.writeHead(404, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(JSON.stringify(refusal(`no such part of /status: ${Object.keys(STATUS_PARTS).join(", ")}`, "status.part")));
2748
+ else
2749
+ keeper.get(version).then((made) => res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(made.parts[part].text), (e) => failed(req, res, e));
2611
2750
  }
2612
2751
  else if (what === "whats-new") {
2613
2752
  // what is new in this strom, from the changelog shipped with it (the app's window "What's new")
@@ -2626,22 +2765,33 @@ export function serveLive(root, env) {
2626
2765
  recentChanges(root, env, hours).then((r) => (r ? json(200, r) : json(404, { error: "the research has no commit yet" })), (e) => failed(req, res, e));
2627
2766
  }
2628
2767
  else if (what === "log") {
2768
+ // on the thread of /status: git asked of the commits it does not know (a hundred at first: a second), each read
2769
+ // as the app reads it (every session, each name and task: a second on a large research, at each change)
2629
2770
  const h = head(root);
2630
- if (!log || log.head !== h)
2631
- log = { head: h, text: JSON.stringify({ entries: histories.log(Tree.open(root, env), h) }) };
2632
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(log.text);
2771
+ const answer = (text) => {
2772
+ if (!req.destroyed)
2773
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }).end(text);
2774
+ };
2775
+ if (log?.head === h)
2776
+ answer(log.text);
2777
+ else
2778
+ logs.ask(h).then(answer, (e) => failed(req, res, e));
2633
2779
  }
2634
2780
  else if (what === "tree.ged" || what === "images.ged") {
2635
2781
  // made when the commits stop coming (an agent's commands one after another): once for them all
2636
2782
  afterQuiet(() => giveGed(res, version).catch((e) => failed(req, res, e)));
2637
2783
  }
2638
2784
  else if (what === "events") {
2639
- const hello = JSON.stringify(status(root, env, version, reads.seen));
2640
- res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
2641
- res.write(`event: hello\ndata: ${hello}\n\n`);
2642
- streams.add(res);
2643
- req.on("close", () => streams.delete(res));
2644
- res.on("error", () => streams.delete(res));
2785
+ statusVersion = version;
2786
+ keeper.get(version).then((made) => {
2787
+ if (req.destroyed)
2788
+ return;
2789
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive" });
2790
+ res.write(`event: hello\ndata: ${made.full}\n\n`);
2791
+ streams.add(res);
2792
+ req.on("close", () => streams.delete(res));
2793
+ res.on("error", () => streams.delete(res));
2794
+ }, (e) => failed(req, res, e));
2645
2795
  }
2646
2796
  else
2647
2797
  res.writeHead(404).end();
@@ -4915,7 +5065,12 @@ export function serveLive(root, env) {
4915
5065
  if (!up)
4916
5066
  resolve();
4917
5067
  });
4918
- server.listen(wanted, "127.0.0.1");
5068
+ // taking over from the bridge of an older strom (it answers meanwhile): /status made first, so the app's first ask
5069
+ // here waits for no thread's start (a few seconds on a slow computer) — at most a while
5070
+ if (env.STROM_LIVE_RENEWED)
5071
+ void keeper.ready(statusVersion, Number(env.STROM_LIVE_READY_MS ?? 10_000)).then(() => server.listen(wanted, "127.0.0.1"));
5072
+ else
5073
+ server.listen(wanted, "127.0.0.1");
4919
5074
  server.once("listening", () => {
4920
5075
  up = true;
4921
5076
  const port = server.address().port;
@@ -4978,7 +5133,16 @@ export function serveLive(root, env) {
4978
5133
  let workers = safe("the start", () => JSON.stringify(atWork(seen))) ?? "";
4979
5134
  // the research on the desktop (the Strom Widget of a Mac, the widget of Windows 11): the tree's overview written
4980
5135
  // while the bridge runs (core/widget.ts; none but on macOS and Windows) — the first a moment after the start, never in its way
4981
- const widget = widgetWriter(root, env);
5136
+ // (worked out on a thread of its own: a second and more on a large research, every check; off: here, as before —
5137
+ // the thread ended with the bridge says nothing)
5138
+ let widgetEnds = false;
5139
+ const widget = widgetWriter(root, env, process.platform, Date.now, env.STROM_LIVE_STATUS_THREAD === "0"
5140
+ ? undefined
5141
+ : (now) => widgetOnThread({ root, env, now }).catch((e) => {
5142
+ if (!widgetEnds)
5143
+ noteLive(root, `the widget failed (tried again): ${errorText(e)}`);
5144
+ throw e;
5145
+ }));
4982
5146
  if (widget)
4983
5147
  setTimeout(() => safe("the widget", () => widget.tick(true)), 1000).unref?.();
4984
5148
  const send = (event, data) => {
@@ -5006,7 +5170,34 @@ export function serveLive(root, env) {
5006
5170
  // something the app sent written now: its body coming, a strom of the bridge's writing it (a send, an adoption, an
5007
5171
  // original, a batch, the person's answer)
5008
5172
  const writesNow = () => incoming > 0 || writing !== undefined || adopting || kids.size > 0;
5009
- const busy = () => writesNow() || retryAt !== undefined;
5173
+ const busy = () => writesNow() || retryAt !== undefined || tidying;
5174
+ // the order kept on a thread of its own (core/livetidy-worker.ts): seconds on a large research, the bridge answers
5175
+ // meanwhile; a thread that cannot start: kept here, as before
5176
+ let tidying = false;
5177
+ const tidyOffThread = () => {
5178
+ let w;
5179
+ try {
5180
+ const env1 = Object.fromEntries(Object.entries(env).filter((e) => typeof e[1] === "string"));
5181
+ w = new Worker(new URL(`./livetidy-worker${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url), { workerData: { root, env: env1 }, env: env1 });
5182
+ }
5183
+ catch (e) {
5184
+ noteLive(root, `keeping order not on its thread (${errorText(e)}): here`);
5185
+ return safe("keeping order", () => autoTidy(Tree.open(root, env)));
5186
+ }
5187
+ tidying = true;
5188
+ w.unref();
5189
+ const from = Date.now();
5190
+ const done = (why) => {
5191
+ if (!tidying)
5192
+ return;
5193
+ tidying = false;
5194
+ noteLive(root, why ? `keeping order: ${why}` : `kept in order on its thread (${Date.now() - from} ms)`);
5195
+ void w.terminate();
5196
+ };
5197
+ w.once("message", (m) => done(m.ok ? undefined : m.message));
5198
+ w.once("error", (e) => done(errorText(e)));
5199
+ w.once("exit", (code) => done(code ? `its thread ended (exit ${code})` : undefined));
5200
+ };
5010
5201
  // The new bridge started while this one still answers: once it runs (its code loaded, a few seconds on a slow
5011
5202
  // computer) it asks this one to end through the stop file and takes the port at once, as a bridge wanted by
5012
5203
  // another strom does — the app waits a moment, not the new one's start (found: ~4.5 s without an answer when this
@@ -5031,7 +5222,7 @@ export function serveLive(root, env) {
5031
5222
  };
5032
5223
  let child;
5033
5224
  try {
5034
- child = spawnBridge(root, env, { STROM_LIVE_RENEWED: disk.version });
5225
+ child = spawnBridge(root, env, { STROM_LIVE_RENEWED: disk.version, STROM_LIVE_STATUS_FOR: statusVersion ?? "" });
5035
5226
  }
5036
5227
  catch (e) {
5037
5228
  renewing = false;
@@ -5061,7 +5252,7 @@ export function serveLive(root, env) {
5061
5252
  let qualitySeen = qualityRev({ root }, "0000000");
5062
5253
  const HISTORY_MS = Number(env.STROM_LIVE_HISTORY_MS ?? 60_000);
5063
5254
  // the first a few minutes after it starts, then once a day
5064
- let tidied = Date.now() - Number(env.STROM_TIDY_MS ?? 24 * 3600_000) + 5 * 60_000;
5255
+ let tidied = Date.now() - Number(env.STROM_TIDY_MS ?? 24 * 3600_000) + Number(env.STROM_TIDY_FIRST_MS ?? 5 * 60_000);
5065
5256
  const timer = setInterval(() => {
5066
5257
  // its research gone (taken off the computer, a test's folder, moved; its .strom deleted): nothing to follow —
5067
5258
  // even while handing over (found: bridges of a folder removed in the middle of it left running for hours)
@@ -5101,12 +5292,15 @@ export function serveLive(root, env) {
5101
5292
  // what strom keeps beside the research in order, once a day (a tree tidied once; never while a send is written)
5102
5293
  if (Date.now() - tidied > Number(env.STROM_TIDY_MS ?? 24 * 3600_000) && !busy()) {
5103
5294
  tidied = Date.now();
5104
- safe("keeping order", () => autoTidy(Tree.open(root, env)));
5295
+ tidyOffThread();
5105
5296
  }
5106
5297
  // the idle time runs only while nobody works in the tree (a run waiting for its gate works too): the app watching
5107
5298
  // the tree without following it (/status?poll=1) still finds the bridge while an agent is at work
5108
5299
  // the queue of the documents the app asked to be read (one at a time); what changed of the readings, for the event read
5109
5300
  const readsChanged = safe("the readings", () => reads.tick()) ?? [];
5301
+ // what /status says of the readings changed without a commit: made again at the next ask
5302
+ if (readsChanged.length)
5303
+ keeper.forget();
5110
5304
  if (streams.size || anyoneAtWork(root) || reads.active())
5111
5305
  lastAsked = Date.now();
5112
5306
  if (idleMs > 0 && Date.now() - lastAsked > idleMs)
@@ -5124,6 +5318,8 @@ export function serveLive(root, env) {
5124
5318
  const entries = histories.since(Tree.open(root, env), seen ? [`-n${LOG_MAX}`, `${seen}..${h}`] : ["-1", h]);
5125
5319
  send("change", { head: h, what: subjects(root, seen, h), at: at ?? new Date().toISOString(), entries });
5126
5320
  seen = h;
5321
+ // the app asks /status after a change: made for the new commit before it asks
5322
+ keeper.warm(statusVersion);
5127
5323
  }
5128
5324
  for (const r of readsChanged)
5129
5325
  send("read", r);
@@ -5198,7 +5394,9 @@ export function serveLive(root, env) {
5198
5394
  clearInterval(keepAlive);
5199
5395
  clearInterval(stopAsked);
5200
5396
  // what a thread makes for the app is wanted no more
5397
+ widgetEnds = true;
5201
5398
  endWork();
5399
+ keeper.end();
5202
5400
  if (stopWaits)
5203
5401
  safe("the stop's wait", () => fs.rmSync(liveBusyFile(root), { force: true }));
5204
5402
  safe("the widget", () => widget?.end(reason === "gone"));
@@ -1,6 +1,6 @@
1
1
  // The bridge's thread for what takes long (core/liveged.ts): one job, its result said back, then it ends.
2
2
  import { parentPort, workerData } from "node:worker_threads";
3
- import { buildGed, excerptAnswer } from "./liveged.js";
3
+ import { buildGed, excerptAnswer, fixesHere, widgetHere } from "./liveged.js";
4
4
  const job = workerData;
5
5
  let done;
6
6
  let move = [];
@@ -11,6 +11,12 @@ try {
11
11
  // the file's bytes handed over, not copied (a buffer of its own: buildGed)
12
12
  move = [made.bytes.buffer];
13
13
  }
14
+ else if (job.job === "widget") {
15
+ done = { ok: true, made: await widgetHere(job.ask) };
16
+ }
17
+ else if (job.job === "fixes") {
18
+ done = { ok: true, made: await fixesHere(job.ask) };
19
+ }
14
20
  else {
15
21
  const a = excerptAnswer(job.ask);
16
22
  done = { ok: true, made: { code: a.code, text: JSON.stringify(a.body) } };
@@ -16,6 +16,7 @@ import { surnamesForExport } from "./surnames.js";
16
16
  import { lineMarks } from "./lineages.js";
17
17
  import { LINK_SCHEME, linkScheme } from "./links.js";
18
18
  import { refusal } from "./receive.js";
19
+ import { actsAsArchive } from "./state.js";
19
20
  /** The tree for the app at a head, for a version of the app (made where it is called: on a thread of its own in the bridge). */
20
21
  export function buildGed(ask) {
21
22
  const { root, env, head: h, version } = ask;
@@ -156,6 +157,31 @@ export async function gedOnThread(ask) {
156
157
  // a buffer came over as its bytes (Uint8Array)
157
158
  return { ...made, bytes: Buffer.from(made.bytes.buffer, made.bytes.byteOffset, made.bytes.byteLength) };
158
159
  }
160
+ /**
161
+ * strom's fixes of a research found on a thread of its own (they read every person, source and reading: seconds on a
162
+ * large research, after each of its commits, as the app asks for them at each new head — found 2026-11-04).
163
+ */
164
+ export async function fixesOnThread(ask) {
165
+ return (await onThread({ job: "fixes", ask })).made;
166
+ }
167
+ /** strom's fixes of a research for the app, found here: the stamp they are found by, and what they say. */
168
+ export async function fixesHere(ask) {
169
+ const m = await import("../commands/fixchecks.js");
170
+ const tree = Tree.open(ask.root, ask.env);
171
+ const settings = new Settings(ask.env, {});
172
+ const archive = actsAsArchive(tree);
173
+ const key = `${m.fixesStamp(tree, m.fixEnvOf(settings, ask.env, tree))}|${archive}`;
174
+ return { key, text: JSON.stringify(m.fixesForApp(tree, settings, ask.env, { archive })) };
175
+ }
176
+ /** The widget's overview of a research worked out on a thread of its own (undefined: an archive, nothing to show). */
177
+ export async function widgetOnThread(ask) {
178
+ return (await onThread({ job: "widget", ask })).made ?? undefined;
179
+ }
180
+ /** The widget's overview of a research, worked out here (the thread's part). */
181
+ export async function widgetHere(ask) {
182
+ const { widgetTree } = await import("./widget.js");
183
+ return widgetTree(ask.root, ask.env, { running: true, now: ask.now }) ?? null;
184
+ }
159
185
  /** One source's excerpts made on a thread of its own: the status and the JSON text. */
160
186
  export async function excerptOnThread(ask) {
161
187
  const done = await onThread({ job: "excerpt", ask });
@@ -0,0 +1,29 @@
1
+ // The bridge's thread for /status (core/livestatus.ts): one for the bridge's life, each ask worked out in turn, what it
2
+ // worked out before (the queue's order, the readings, the history's rev) kept between them.
3
+ import { parentPort, workerData } from "node:worker_threads";
4
+ import { HistoryCache, historyRevLearn, status } from "./live.js";
5
+ import { Tree } from "./tree.js";
6
+ import { ReadingsSeen } from "./live-read.js";
7
+ import { splitStatus } from "./livestatus.js";
8
+ const { root, env } = workerData;
9
+ const seen = new ReadingsSeen(root, env);
10
+ // what git said of the commits, kept: a /log asks it about the new ones only
11
+ const histories = new HistoryCache(root);
12
+ parentPort.on("message", (job) => {
13
+ let done;
14
+ try {
15
+ if ("log" in job) {
16
+ const made = JSON.stringify({ entries: histories.log(Tree.open(root, env), job.log) });
17
+ parentPort.postMessage({ id: job.id, ok: true, made });
18
+ return;
19
+ }
20
+ // the time it was asked: an ask made while this one was worked out is newer
21
+ const at = Date.now();
22
+ historyRevLearn(root, job.known);
23
+ done = { id: job.id, ok: true, made: splitStatus(status(root, env, job.version, seen), job.key, at) };
24
+ }
25
+ catch (e) {
26
+ done = { id: job.id, ok: false, message: e?.message ?? String(e), ...(e?.stack ? { stack: e.stack } : {}) };
27
+ }
28
+ parentPort.postMessage(done);
29
+ });
@@ -0,0 +1,227 @@
1
+ // The bridge's /status (core/live.ts) made on a thread of its own and kept for the state of the research it was made
2
+ // for. Working it out reads the whole research — every task for the queue, what waits for the person, the places, the
3
+ // lines — half a second on a research of a thousand tasks, seconds on a busy computer: made on the bridge's thread it
4
+ // held every other answer meanwhile, and the app, asking now and then with a short patience, said the research does not
5
+ // answer (found 2026-11-04 on a research of ~470 tasks in the queue while its agent wrote). Now:
6
+ // - made once for a state (the commit, who is at work, the tree's and this computer's settings, the day, the agents
7
+ // here, the history's rev, strom on disk) and answered from what was made while that state lasts; looked at again in
8
+ // the background after STATUS_FRESH_MS, waited for as a new state after STATUS_OLD_MS;
9
+ // - made on a thread of its own (livestatus-worker.ts, one for the bridge's life, keeping what it worked out), so the
10
+ // bridge answers everything else meanwhile; one making at a time, the asks for the same state wait for it together;
11
+ // - an ask while the research changes waits for the new state at most STATUS_WAIT_MS, else is answered what was made
12
+ // for the state before (never nothing: the app's patience is short) — the next ask has the new one;
13
+ // - the parts that weigh (what waits, the queue, the work off the lines, the lines) each with its rev: an app that
14
+ // says parts=1 is answered the rest and the revs, and asks a part (GET /status/<part>) only when its rev changed.
15
+ import path from "node:path";
16
+ import crypto from "node:crypto";
17
+ import { fileURLToPath } from "node:url";
18
+ import { execFile } from "node:child_process";
19
+ import { Worker } from "node:worker_threads";
20
+ /** The parts of /status that weigh, each with the fields of /status it carries (GET /status/<part>, feature status.parts). */
21
+ export const STATUS_PARTS = {
22
+ waiting: ["waiting"],
23
+ queue: ["queue", "queueMore", "queueCounts"],
24
+ offLine: ["offLine"],
25
+ researches: ["researches"],
26
+ };
27
+ export function isStatusPart(name) {
28
+ return !!name && Object.hasOwn(STATUS_PARTS, name);
29
+ }
30
+ /** The rev of a part: what it says, in short (the same JSON, the same rev). */
31
+ function revOf(text) {
32
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
33
+ }
34
+ /** /status as worked out, made into the answers (made where it is worked out: on the thread). */
35
+ export function splitStatus(status, key, at = Date.now()) {
36
+ const parts = {};
37
+ const lite = { ...status };
38
+ const revs = {};
39
+ for (const [name, fields] of Object.entries(STATUS_PARTS)) {
40
+ const of = {};
41
+ for (const f of fields) {
42
+ if (status[f] !== undefined)
43
+ of[f] = status[f];
44
+ delete lite[f];
45
+ }
46
+ const said = JSON.stringify(of);
47
+ const rev = revOf(said);
48
+ revs[name] = rev;
49
+ parts[name] = { rev, text: JSON.stringify({ part: name, rev, head: status.head, ...of }) };
50
+ }
51
+ return { key, at, full: JSON.stringify(status), lite: JSON.stringify({ ...lite, parts: revs }), parts };
52
+ }
53
+ /** Where the thread's code is: next to this file, the same kind (the sources run as .ts, the package as .js). */
54
+ const WORKER = new URL(`./livestatus-worker${path.extname(fileURLToPath(import.meta.url))}`, import.meta.url);
55
+ /** How long what was made is answered without looking again (a background look after it). */
56
+ export const STATUS_FRESH_MS = 5_000;
57
+ /**
58
+ * How old what was made may be answered at once (made again in the background): what /status says beside the state
59
+ * (the phone, the spend, a newer strom) is at most that late. Older: made again, waited for as a new state.
60
+ */
61
+ export const STATUS_OLD_MS = 60_000;
62
+ /** How long an ask waits for the new state when what was made is of another state (then: what was made). */
63
+ export const STATUS_WAIT_MS = 1_500;
64
+ /** The commit the research is at: asked of git without holding the bridge (another process). */
65
+ function headAsync(root, git) {
66
+ return new Promise((resolve) => {
67
+ execFile(git, ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true, timeout: 10_000 }, (err, out) => resolve(err ? "" : String(out).trim()));
68
+ });
69
+ }
70
+ /** /status of one bridge: made on a thread, kept for its state, answered at once while that state lasts. */
71
+ export class StatusKeeper {
72
+ o;
73
+ worker;
74
+ broken = false;
75
+ seq = 0;
76
+ waiting = new Map();
77
+ /** What was made last, for each version of the app. */
78
+ made = new Map();
79
+ /** What is being made, for each version and state. */
80
+ making = new Map();
81
+ /** Made again at the next ask whatever the state says (a write of the app's, written by the bridge). */
82
+ stale = false;
83
+ ended = false;
84
+ constructor(o) {
85
+ this.o = o;
86
+ }
87
+ /** The state of the research now: its commit and what changes beside it. */
88
+ async keyNow() {
89
+ const h = await headAsync(this.o.root, this.o.git ?? "git");
90
+ return `${h}\n${this.o.stateNow()}`;
91
+ }
92
+ /** What changed beside the state (the app wrote through the bridge): made again at the next ask. */
93
+ forget() {
94
+ this.stale = true;
95
+ }
96
+ /**
97
+ * /status for the app of that version: what was made for the state now (looked at again in the background once
98
+ * older than STATUS_FRESH_MS), else made now — waited for at most STATUS_WAIT_MS when something was made before.
99
+ */
100
+ async get(version) {
101
+ const v = version ?? "";
102
+ const key = await this.keyNow();
103
+ const had = this.made.get(v);
104
+ const age = had ? Date.now() - had.at : Infinity;
105
+ if (had && had.key === key && !this.stale && age <= STATUS_OLD_MS) {
106
+ if (age > STATUS_FRESH_MS)
107
+ this.make(version, key).catch(() => undefined);
108
+ return had;
109
+ }
110
+ this.stale = false;
111
+ const fresh = this.make(version, key);
112
+ if (!had)
113
+ return fresh;
114
+ let timer;
115
+ const late = new Promise((resolve) => {
116
+ timer = setTimeout(() => resolve(had), Number(this.o.env.STROM_LIVE_STATUS_WAIT_MS ?? STATUS_WAIT_MS));
117
+ });
118
+ try {
119
+ return await Promise.race([fresh, late]);
120
+ }
121
+ finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+ /** Made before the app asks (a new commit heard): ready when it asks. */
126
+ warm(version) {
127
+ if (this.ended)
128
+ return;
129
+ this.keyNow()
130
+ .then((key) => (this.made.get(version ?? "")?.key === key ? undefined : this.make(version, key)))
131
+ .catch(() => undefined);
132
+ }
133
+ /** /status made for the state now before the bridge answers (it takes over from another): waited for at most ms. */
134
+ ready(version, ms) {
135
+ let timer;
136
+ return Promise.race([
137
+ this.get(version).then(() => undefined, () => undefined),
138
+ new Promise((resolve) => (timer = setTimeout(resolve, ms))),
139
+ ]).finally(() => clearTimeout(timer));
140
+ }
141
+ /** The thread ended with the bridge. */
142
+ end() {
143
+ this.ended = true;
144
+ const w = this.worker;
145
+ this.worker = undefined;
146
+ if (w)
147
+ void w.terminate();
148
+ }
149
+ make(version, key) {
150
+ const v = version ?? "";
151
+ const id = `${v}\n${key}`;
152
+ const had = this.making.get(id);
153
+ if (had)
154
+ return had;
155
+ const p = this.onThread(version, key)
156
+ .catch((e) => {
157
+ // a thread that fails: worked out here, as before threads (the answer matters more than the bridge's time)
158
+ this.o.note(`/status not made on its thread (${e.message}): made on the bridge's`);
159
+ return this.o.inline(version, key);
160
+ })
161
+ .then((m) => {
162
+ const before = this.made.get(v);
163
+ if (!before || before.at <= m.at)
164
+ this.made.set(v, m);
165
+ return m;
166
+ })
167
+ .finally(() => this.making.delete(id));
168
+ this.making.set(id, p);
169
+ return p;
170
+ }
171
+ /** /log at a commit (its JSON text): on the thread, in turn with /status; here when it cannot be. */
172
+ log(head) {
173
+ const here = () => this.o.logHere(head);
174
+ if (this.broken || this.o.env.STROM_LIVE_STATUS_THREAD === "0")
175
+ return Promise.resolve().then(here);
176
+ const w = this.thread();
177
+ const id = ++this.seq;
178
+ return new Promise((resolve, reject) => {
179
+ this.waiting.set(id, { resolve, reject });
180
+ w.postMessage({ id, log: head });
181
+ }).catch(here);
182
+ }
183
+ onThread(version, key) {
184
+ if (this.broken || this.o.env.STROM_LIVE_STATUS_THREAD === "0")
185
+ return Promise.resolve(this.o.inline(version, key));
186
+ const w = this.thread();
187
+ const id = ++this.seq;
188
+ return new Promise((resolve, reject) => {
189
+ this.waiting.set(id, { resolve, reject });
190
+ w.postMessage({ id, version, key, known: this.o.known?.() });
191
+ });
192
+ }
193
+ thread() {
194
+ if (this.worker)
195
+ return this.worker;
196
+ const env = Object.fromEntries(Object.entries(this.o.env).filter((e) => typeof e[1] === "string"));
197
+ const w = new Worker(WORKER, { workerData: { root: this.o.root, env }, env });
198
+ // nothing to wait for once the bridge ends
199
+ w.unref();
200
+ w.on("message", (done) => {
201
+ const asked = this.waiting.get(done.id);
202
+ if (!asked)
203
+ return;
204
+ this.waiting.delete(done.id);
205
+ if (done.ok)
206
+ asked.resolve(done.made);
207
+ else
208
+ asked.reject(Object.assign(new Error(done.message), done.stack ? { stack: done.stack } : {}));
209
+ });
210
+ const gone = (why) => {
211
+ if (this.worker === w)
212
+ this.worker = undefined;
213
+ // a thread that cannot start or dies: the next ones made here (once said)
214
+ if (!this.ended && !this.broken) {
215
+ this.broken = true;
216
+ this.o.note(`the thread of /status ended (${why}): /status made on the bridge's thread`);
217
+ }
218
+ for (const [, asked] of this.waiting)
219
+ asked.reject(new Error(why));
220
+ this.waiting.clear();
221
+ };
222
+ w.on("error", (e) => gone(e.message));
223
+ w.on("exit", (code) => gone(`exit ${code}`));
224
+ this.worker = w;
225
+ return w;
226
+ }
227
+ }
@@ -0,0 +1,14 @@
1
+ // The bridge's order kept once a day (core/tidy.ts autoTidy) on a thread of its own: the summary of the reading of scans
2
+ // it makes again reads every task and session of the research — seconds on a research of thousands of tasks, ~11 s on
3
+ // one of 5 000 (found 2026-11-04) — and on the bridge's thread nothing else was answered meanwhile.
4
+ import { parentPort, workerData } from "node:worker_threads";
5
+ import { Tree } from "./tree.js";
6
+ import { autoTidy } from "./tidy.js";
7
+ const { root, env } = workerData;
8
+ try {
9
+ autoTidy(Tree.open(root, env));
10
+ parentPort.postMessage({ ok: true });
11
+ }
12
+ catch (e) {
13
+ parentPort.postMessage({ ok: false, message: e?.message ?? String(e) });
14
+ }
package/dist/core/tree.js CHANGED
@@ -31,7 +31,7 @@ export const TREE_FILE = "strom.json";
31
31
  export const SEAL_FOREIGN = "seal.foreign";
32
32
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
33
33
  // at a release the release's own (package.json)
34
- export const VERSION = "1.26.0-beta.7";
34
+ export const VERSION = "1.26.0";
35
35
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
36
36
  .strom/
37
37
  /media/
@@ -450,8 +450,12 @@ export function dropWidgetTree(dir, key, last) {
450
450
  }
451
451
  writeWidgetIndex(dir);
452
452
  }
453
- /** The writer of a tree's bridge; none but on macOS and Windows. */
454
- export function widgetWriter(root, env, platform = process.platform, clock = Date.now) {
453
+ /**
454
+ * The writer of a tree's bridge; none but on macOS and Windows. `make`: the overview worked out elsewhere (the bridge: on
455
+ * a thread of its own — it reads every task, person and finding: a second and more on a large research, every
456
+ * WIDGET_CHECK_MS, the bridge answering nothing meanwhile; found 2026-11-04); without it, here.
457
+ */
458
+ export function widgetWriter(root, env, platform = process.platform, clock = Date.now, make) {
455
459
  const dir = widgetDir(env, platform);
456
460
  if (!dir)
457
461
  return undefined;
@@ -475,6 +479,27 @@ export function widgetWriter(root, env, platform = process.platform, clock = Dat
475
479
  said = c;
476
480
  written = now;
477
481
  };
482
+ // an overview being worked out elsewhere (`make`), and the bridge ended meanwhile (what comes after is written by nobody)
483
+ let making = false;
484
+ let ended = false;
485
+ const take = (t, now) => {
486
+ if (!t) {
487
+ // an archive now: nothing of it on the desktop
488
+ if (key)
489
+ dropWidgetTree(dir, key, last);
490
+ key = undefined;
491
+ last = undefined;
492
+ held = undefined;
493
+ said = "";
494
+ return;
495
+ }
496
+ key = t.treeKey;
497
+ last = t;
498
+ const c = content(t);
499
+ if (c === said && now - written < beatMs && !held)
500
+ return;
501
+ write(t, c, now);
502
+ };
478
503
  return {
479
504
  tick(force = false) {
480
505
  const now = clock();
@@ -483,26 +508,24 @@ export function widgetWriter(root, env, platform = process.platform, clock = Dat
483
508
  write({ ...held.t, generatedAt: isoSecond(now), lastActiveAt: isoSecond(now) }, held.c, now);
484
509
  return;
485
510
  }
486
- checked = now;
487
- const t = widgetTree(root, env, { running: true, now });
488
- if (!t) {
489
- // an archive now: nothing of it on the desktop
490
- if (key)
491
- dropWidgetTree(dir, key, last);
492
- key = undefined;
493
- last = undefined;
494
- held = undefined;
495
- said = "";
496
- return;
511
+ if (!make) {
512
+ checked = now;
513
+ return take(widgetTree(root, env, { running: true, now }), now);
497
514
  }
498
- key = t.treeKey;
499
- last = t;
500
- const c = content(t);
501
- if (c === said && now - written < beatMs && !held)
515
+ if (making)
502
516
  return;
503
- write(t, c, now);
517
+ checked = now;
518
+ making = true;
519
+ make(now).then((t) => {
520
+ making = false;
521
+ if (!ended)
522
+ take(t, clock());
523
+ },
524
+ // (said by `make`): looked at again at the next check
525
+ () => (making = false));
504
526
  },
505
527
  end(gone) {
528
+ ended = true;
506
529
  if (!key)
507
530
  return;
508
531
  // what the file says now is no longer what was written: the next look writes it again
@@ -108,13 +108,17 @@ export class GedWriter {
108
108
  for (let i = first; i < value.length; i += next)
109
109
  this.raw(level + 1, "CONC", value.slice(i, i + next));
110
110
  }
111
- /** A tag with free text of any length: CONT for line breaks, CONC for long lines. */
111
+ /**
112
+ * A tag with free text of any length: CONT for line breaks, CONC for long lines. The text as it is written, its
113
+ * spaces kept — two between words, a no-break or narrow one (found 2026-11-04: a story's two spaces came to the app
114
+ * as one); only a tab or a form feed is a space, and the spaces round a line go (a line's value starts after one).
115
+ */
112
116
  text(level, tag, text) {
113
117
  const paragraphs = text
114
118
  .normalize("NFC")
115
119
  .replace(/\r\n?/g, "\n")
116
120
  .split("\n")
117
- .map((p) => p.replace(/\s+/g, " ").trim().replace(/@/g, "@@"));
121
+ .map((p) => p.replace(/[\t\v\f]/g, " ").trim().replace(/@/g, "@@"));
118
122
  paragraphs.forEach((para, i) => {
119
123
  if (i === 0)
120
124
  this.long(level, tag, para);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.26.0-beta.7",
3
+ "version": "1.26.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "stromLayout": 2,
@@ -59,8 +59,5 @@
59
59
  "devDependencies": {
60
60
  "@types/node": "^22.20.4",
61
61
  "typescript": "^7.0.2"
62
- },
63
- "publishConfig": {
64
- "tag": "beta"
65
62
  }
66
63
  }