strom-research 1.1.1 → 1.2.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/README.md CHANGED
@@ -58,7 +58,9 @@ npm: `npm install -g strom-research@latest`.
58
58
  OpenCode), experienced users in the terminal — strom opens it in the right
59
59
  folder, set up as you chose
60
60
  - Or let the agent work on its own through the task queue (`strom run`) and
61
- read the result
61
+ read the result — or on and on (`strom run --loop`) while a condition of
62
+ yours allows it: a gate plugin, e.g. `claude-usage 10` (your Claude
63
+ subscription's daily ration, 10 points in hand), or one of your own (`strom gate list`)
62
64
  - Several agents can work on one family tree side by side, each on its own task
63
65
 
64
66
  **Evidence first**
@@ -428,6 +428,7 @@
428
428
  "ui.stats.tasks": "Úkoly: ve frontě {queued} · čekají na vás {waiting} · hotové {done}",
429
429
  "ui.stats.sessions": "Sezení agenta: {n} · poslední {last}",
430
430
  "ui.stats.cost": " · dosavadní cena {cost}",
431
+ "ui.stats.cost.partial": " (bez přerušených sezení, která cenu neřekla: {n})",
431
432
  "ui.stats.stories": "Vyprávění: {n} (schválená {final})",
432
433
  "ui.ped.noparents": "rodiče zatím neznámí",
433
434
  "ui.ped.nofather": "otec zatím neznámý",
@@ -474,5 +475,19 @@
474
475
  "ui.menu.working.more": "Samostatně teď pracují agenti: {n}.",
475
476
  "ui.run.beside": "⚠ Agent už tu pracuje sám (běhy: {n}). Další vedle něj si vezme jiné úkoly – náklady se sčítají.",
476
477
  "ui.run.beside.sure": "Spustit další vedle něj?",
478
+ "ui.run.gate": "· podmínka: {name} — ptá se před každým sezením; běh může nechat čekat",
479
+ "ui.run.gate.wait": "⏸ čekám do {at} — {reason} (Ctrl-C běh ukončí)",
480
+ "ui.run.stop.gate": "podmínka „{name}“ řekla dost — {reason}",
481
+ "ui.run.stop.gate.error": "podmínka „{name}“ neodpověděla, jak má — {reason} (strom gate test)",
482
+ "ui.run.gate.once": "· podmínka: {name} — tyto úkoly spouštíte vy: zeptám se jí jednou, a kdyby nespouštěla, rozhodnete vy",
483
+ "ui.run.gate.anyway": "Podmínka „{name}“ by teď nespouštěla — {reason}. Spustit přesto?",
484
+ "ui.run.gate.later": "zatím ne",
485
+ "ui.run.gate.no": "ne",
486
+ "ui.run.gate.noanswer": "neodpověděla, jak má (strom gate test)",
487
+ "ui.run.stop.gate.declined": "nespuštěno — podmínka „{name}“: {reason}",
488
+ "ui.run.loop": "Nechat ho pracovat, dokud je co dělat a dovolí to podmínka „{name}“",
489
+ "ui.consent.gate.set": "Nechat podmínku „{name}“ rozhodovat, kdy agent pracující sám pokračuje?",
490
+ "ui.consent.gate.unset": "Odebrat podmínku „{name}“ — agent pracující sám se jí už nebude ptát?",
491
+ "ui.consent.gate.skip": "Nechat agenta pracovat samotného bez podmínky „{name}“?",
477
492
  "ui.run.others": "⚠ V tomto rodokmenu už pracuje jiný běh (od {since}): tento si vezme jiné úkoly – náklady se sčítají."
478
493
  }
@@ -428,6 +428,7 @@
428
428
  "ui.stats.tasks": "Aufgaben: in der Warteschlange {queued} · warten auf Sie {waiting} · erledigt {done}",
429
429
  "ui.stats.sessions": "Sitzungen des Agenten: {n} · die letzte {last}",
430
430
  "ui.stats.cost": " · Kosten bisher {cost}",
431
+ "ui.stats.cost.partial": " (ohne abgebrochene Sitzungen, die ihre Kosten nicht nannten: {n})",
431
432
  "ui.stats.stories": "Geschichten: {n} (freigegeben {final})",
432
433
  "ui.ped.noparents": "Eltern noch unbekannt",
433
434
  "ui.ped.nofather": "Vater noch unbekannt",
@@ -474,5 +475,19 @@
474
475
  "ui.menu.working.more": "Gerade arbeiten Agenten selbstständig: {n}.",
475
476
  "ui.run.beside": "⚠ Hier arbeitet schon ein Agent selbstständig (Läufe: {n}). Ein weiterer daneben nimmt andere Aufgaben – die Kosten addieren sich.",
476
477
  "ui.run.beside.sure": "Einen weiteren daneben starten?",
478
+ "ui.run.gate": "· Bedingung: {name} — vor jeder Sitzung gefragt; sie kann den Lauf warten lassen",
479
+ "ui.run.gate.wait": "⏸ warte bis {at} — {reason} (Strg-C beendet den Lauf)",
480
+ "ui.run.stop.gate": "die Bedingung „{name}“ sagte Halt — {reason}",
481
+ "ui.run.stop.gate.error": "die Bedingung „{name}“ gab keine gültige Antwort — {reason} (strom gate test)",
482
+ "ui.run.gate.once": "· Bedingung: {name} — diese Aufgaben starten Sie selbst: sie wird einmal gefragt, und würde sie nicht starten, entscheiden Sie",
483
+ "ui.run.gate.anyway": "Die Bedingung „{name}“ würde jetzt nicht starten — {reason}. Trotzdem starten?",
484
+ "ui.run.gate.later": "noch nicht",
485
+ "ui.run.gate.no": "nein",
486
+ "ui.run.gate.noanswer": "keine gültige Antwort (strom gate test)",
487
+ "ui.run.stop.gate.declined": "nicht gestartet — die Bedingung „{name}“: {reason}",
488
+ "ui.run.loop": "Weiterarbeiten lassen, solange es Arbeit gibt und die Bedingung „{name}“ es erlaubt",
489
+ "ui.consent.gate.set": "Die Bedingung „{name}“ entscheiden lassen, wann der allein arbeitende Agent weitermacht?",
490
+ "ui.consent.gate.unset": "Die Bedingung „{name}“ entfernen — der allein arbeitende Agent fragt sie nicht mehr?",
491
+ "ui.consent.gate.skip": "Den Agenten ohne die Bedingung „{name}“ allein arbeiten lassen?",
477
492
  "ui.run.others": "⚠ In diesem Stammbaum arbeitet schon ein anderer Lauf (seit {since}): dieser nimmt andere Aufgaben – die Kosten addieren sich."
478
493
  }
@@ -28,8 +28,12 @@ conclusion so the next researcher can follow it.
28
28
  fact is edited with a reason. Contradictions become conflicts, competing
29
29
  explanations become hypotheses — never silently pick one.
30
30
  - **Write as you go.** Record each finding as soon as you have it. Your context
31
- can be cut or summarised at any moment; what is only in the conversation is
32
- lost, what is in strom is not.
31
+ can be cut or summarised at any moment, and a session working alone is
32
+ stopped at its time limit (the brief says when; near the end strom's output
33
+ counts down): what is only in the conversation is lost, what is in strom is
34
+ not. Going through many images, record each stretch when it is done (every
35
+ ten images, each district or year): the finds, and `strom search add …
36
+ --pages` for what was searched in vain — never all of it at the end.
33
37
  - **Lessons belong where they apply.** A quirk of a register (its calibration,
34
38
  two years per page, a hand that writes 7 like 1) goes to `strom lesson add
35
39
  --on B…`, so whoever opens that book next sees it.
@@ -23,7 +23,8 @@ Look at as few pixels as the question needs, and write down what you saw at once
23
23
  foreground and let it finish — your session ends with your turn, and
24
24
  whatever is left running in the background ends with it.
25
25
  (Your own subagents can read too, in batches of at most twelve — but what they
26
- report is lost with your session unless you write it down.) Only the entries
26
+ report is lost with your session unless you write it down: record each
27
+ report as it comes back, before you send the next readers.) Only the entries
27
28
  that will be cited need your own eyes, at full resolution.
28
29
  - Old handwriting is decoded, not copied: never a weaker model for handwriting,
29
30
  never a guess. "Illegible" is a valid and valuable answer.
@@ -7,6 +7,9 @@ it one folder per plugin. The folder's name is the plugin's name.
7
7
  connectors/ downloaders for archive portals
8
8
  README.md their interface (version 1)
9
9
  example-archive/ one connector: connector.json and its program
10
+ gates/ conditions on the agent working alone (strom run)
11
+ README.md their interface (version 1)
12
+ claude-usage/ one gate: gate.json and its program
10
13
 
11
14
  **Install** a plugin by copying its folder in: `strom connector list` shows it
12
15
  and it can run at once, paced by strom and only to the hosts it names. To be
@@ -18,6 +21,10 @@ plugin whose code goes round strom always needs your yes, in your terminal
18
21
 
19
22
  **Remove** one by deleting its folder (or `strom connector remove <name>`).
20
23
 
24
+ **Gates** decide whether the agent working alone goes on: `strom config set
25
+ run.gate <name>` (only you), `strom gate test`, `strom run --loop`.
26
+
21
27
  Plugins are programs on this computer, put here by you: the `.gitignore` here
22
- keeps them out of any repository. strom writes this file, the `.gitignore` and
23
- `connectors/README.md`, and keeps them up to date.
28
+ keeps them out of any repository. strom writes this file, the `.gitignore`,
29
+ `connectors/README.md`, `gates/README.md` and the gates it ships, and keeps them
30
+ up to date.
@@ -0,0 +1,95 @@
1
+ # strom gates — interface 1
2
+
3
+ A gate is a condition you set on the agent working alone. Before each session
4
+ of `strom run` strom asks your gate: go on, wait, or stop? With
5
+ `strom run --loop` the agent works on for as long as there is work and the gate
6
+ lets it — your subscription's room, a budget, the night's tariff: whatever the
7
+ gate's program decides.
8
+
9
+ plugins/gates/
10
+ README.md this interface
11
+ claude-usage/ a gate strom ships (the Claude subscription's daily ration)
12
+ my-gate/ one of yours: gate.json and its program
13
+
14
+ **Use one**: `strom config set run.gate <name>` — or its name and what it is
15
+ given, `"claude-usage 10"` (only you: an agent cannot set or remove it);
16
+ `strom gate test` to see its answer now. `strom gate list` shows
17
+ the gates here. `strom config unset run.gate`: no gate.
18
+
19
+ ## gate.json
20
+
21
+ {
22
+ "interface": 1,
23
+ "title": "My budget",
24
+ "command": ["node", "gate.ts"],
25
+ "timeout": 60
26
+ }
27
+
28
+ - `command`: the program and its arguments, run in the gate's folder. `node`
29
+ is strom's own Node (TypeScript runs as it is) — best on every system. What
30
+ the user gives the gate after its name (`run.gate "my-gate 20 night"`) is
31
+ added to these arguments.
32
+ - `timeout`: seconds it may take (default 120). No answer in time: an error.
33
+
34
+ ## The answer
35
+
36
+ The exit status says it:
37
+
38
+ | status | meaning |
39
+ |---|---|
40
+ | 0 | go on: the next session starts |
41
+ | 1 | wait: strom asks again later (the computer may sleep meanwhile) |
42
+ | 2 | stop: the run ends |
43
+ | anything else, a crash, no answer | an error: the run ends — never spending blind |
44
+
45
+ One line of JSON on stdout (the last line) may say more:
46
+
47
+ {"reason": "the week is at 61 %", "until": "2026-10-02T14:00:00Z"}
48
+
49
+ - `reason`: why, for the user (in their language: `STROM_LANG`).
50
+ - `wait` (seconds) or `until` (a time): when to ask again. Without them: in
51
+ 15 minutes; never sooner than a minute.
52
+
53
+ Plain text instead of JSON is the reason.
54
+
55
+ ## What strom tells the gate
56
+
57
+ Environment variables, besides the user's own:
58
+
59
+ | variable | |
60
+ |---|---|
61
+ | `STROM_GATE` | the gate's name |
62
+ | `STROM_TREE` | the family tree's folder |
63
+ | `STROM_LANG` | the research language (cs, de, en…) |
64
+ | `STROM_AGENT` | the agent: claude, codex, antigravity, opencode |
65
+ | `STROM_MODEL` | the main model, when one is set |
66
+ | `STROM_SESSIONS` | sessions this run has had |
67
+ | `STROM_COST_USD` | what this run has cost so far (as the agent reported it) |
68
+ | `STROM_NEXT_TASK` | the task the next session would take |
69
+
70
+ A session already at work is never stopped by a gate: it is asked between
71
+ sessions only (a session's own time limit is `run.minutes`).
72
+
73
+ The gate holds the runs that go on by themselves: `strom run --loop` and
74
+ `--until`. Tasks you start yourself (one, `--max 3`, `--task T0007`, "work
75
+ alone" in the menu) are your choice: the gate is asked once, and when it would
76
+ not start now, strom tells you why and asks whether to start anyway (no is
77
+ suggested). An agent starting them needs your yes in a window of the system;
78
+ a script nobody can ask keeps to the gate.
79
+
80
+ ## claude-usage
81
+
82
+ Keeps the work to the daily ration of your Claude subscription. It asks Claude
83
+ Code (`claude -p /usage`) how much of the week is used and counts how much of
84
+ the week is gone since its reset (a day ≈ 14.3 %). A session starts only while
85
+ usage is at least the number you give it behind the week gone:
86
+
87
+ strom config set run.gate "claude-usage 10" gone − used ≥ 10
88
+
89
+ Without a number: 0 — no faster than the week goes; 14: about a day in hand.
90
+ Not enough in hand: it waits until the week has caught up (past the reset: the
91
+ new week's share). A used-up session: until it resets. Another agent than
92
+ Claude Code: it lets the work go on.
93
+
94
+ A gate is a program on this computer, put here by you (or by strom); the
95
+ `.gitignore` of the plugins folder keeps it out of any repository.
@@ -0,0 +1,10 @@
1
+ {
2
+ "interface": 1,
3
+ "title": "Claude usage",
4
+ "description": "Keeps working alone to the daily ration of your Claude subscription: a session starts only while the week's usage is at least n points behind the share of the week gone (a day ≈ 14.3): strom config set run.gate \"claude-usage 10\" (without a number: 0).",
5
+ "command": [
6
+ "node",
7
+ "gate.ts"
8
+ ],
9
+ "timeout": 120
10
+ }
@@ -0,0 +1,125 @@
1
+ // A strom gate (interface 1): let the research work alone while the Claude
2
+ // subscription has room. It asks Claude Code itself (`claude -p /usage`, the
3
+ // plan's limits as the server reports them) and answers strom:
4
+ // exit 0 go on exit 1 wait (JSON "until": when to ask again)
5
+ // exit 2 stop the run — one line of JSON on stdout says why.
6
+ //
7
+ // The rule, the pace of the week: the share of the week gone since its reset
8
+ // (a day is about 14.3 %) against the week's usage. With the number you give
9
+ // it (strom config set run.gate "claude-usage 10") a session starts only while
10
+ // usage is at least that many points behind the week gone: gone − used ≥ 10.
11
+ // Without a number: 0 — no faster than the week goes. Else it waits until the
12
+ // week has caught up (or past the reset); a full session: until it resets.
13
+ //
14
+ // strom writes this file; a copy of your own goes into a folder of its own name.
15
+
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ const DAY = 86_400_000;
19
+ const WEEK = 7 * DAY;
20
+ const lang = (process.env.STROM_LANG ?? "en").slice(0, 2);
21
+
22
+ const T: Record<string, Record<string, string>> = {
23
+ en: {
24
+ other: "the agent is not Claude Code — nothing to check",
25
+ api: "Claude Code runs on an API key, not a subscription — no plan limits",
26
+ go: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed)",
27
+ session: "the session is used up — until it resets",
28
+ week: "week {used} % used, {gone} % gone, {behind} in hand (at least {min} needed) — until the week catches up",
29
+ bad: "the number must be 0 to 99, not \"{arg}\"",
30
+ unread: "cannot read `claude -p /usage`: {detail}",
31
+ },
32
+ cs: {
33
+ other: "agent není Claude Code — není co hlídat",
34
+ api: "Claude Code běží na API klíči, ne na předplatném — žádné limity plánu",
35
+ go: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min})",
36
+ session: "sezení je vyčerpané — do jeho obnovení",
37
+ week: "týden: využito {used} %, uplynulo {gone} %, náskok {behind} (potřeba aspoň {min}) — dokud čas nedožene",
38
+ bad: "číslo musí být 0 až 99, ne \"{arg}\"",
39
+ unread: "nelze přečíst `claude -p /usage`: {detail}",
40
+ },
41
+ de: {
42
+ other: "der Agent ist nicht Claude Code — nichts zu prüfen",
43
+ api: "Claude Code läuft mit einem API-Schlüssel, nicht im Abo — keine Planlimits",
44
+ go: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig)",
45
+ session: "die Sitzung ist aufgebraucht — bis sie zurückgesetzt wird",
46
+ week: "Woche: {used} % genutzt, {gone} % vergangen, Vorsprung {behind} (mindestens {min} nötig) — bis die Zeit aufholt",
47
+ bad: "die Zahl muss 0 bis 99 sein, nicht \"{arg}\"",
48
+ unread: "`claude -p /usage` nicht lesbar: {detail}",
49
+ },
50
+ };
51
+ function say(key: string, v: Record<string, string | number> = {}): string {
52
+ const s = (T[lang] ?? T.en!)[key] ?? T.en![key]!;
53
+ return s.replace(/\{(\w+)\}/g, (_, k: string) => String(v[k] ?? ""));
54
+ }
55
+ function answer(code: 0 | 1 | 2, reason: string, until?: Date): never {
56
+ console.log(JSON.stringify({ reason, ...(until ? { until: until.toISOString() } : {}) }));
57
+ process.exit(code);
58
+ }
59
+
60
+ // ── your number: how many points usage must be behind the week gone ─────────
61
+ const arg = process.argv[2] ?? "0";
62
+ const min = Number(arg);
63
+ if (!/^\d{1,2}$/.test(arg)) answer(2, say("bad", { arg }));
64
+
65
+ if ((process.env.STROM_AGENT ?? "claude") !== "claude") answer(0, say("other"));
66
+
67
+ // ── what Claude Code says ───────────────────────────────────────────────────
68
+ const r = spawnSync("claude", ["-p", "/usage"], { encoding: "utf8", timeout: 90_000, shell: process.platform === "win32", windowsHide: true });
69
+ if (r.error || r.status !== 0) answer(2, say("unread", { detail: r.error?.message ?? (r.stderr || r.stdout || `exit ${r.status}`).trim().split("\n")[0] ?? "" }));
70
+ const text = r.stdout;
71
+ if (/API key/i.test(text) && !/Current week/i.test(text)) answer(0, say("api"));
72
+
73
+ const LINE = /^Current (session|week)(?: \(([^)]+)\))?:\s*(\d+)%\s*used(?:\s*·\s*resets\s*(.+))?$/i;
74
+ let session: { used: number; resets?: Date } | undefined;
75
+ let week: { used: number; resets?: Date } | undefined;
76
+ for (const raw of text.split(/\r?\n/)) {
77
+ const m = LINE.exec(raw.trim());
78
+ if (!m) continue;
79
+ const item = { used: Number(m[3]), ...(m[4] ? { resets: resetAt(m[4]) } : {}) };
80
+ if (m[1]!.toLowerCase() === "session") session = item;
81
+ // the limit of all models is the one that stops the work; a model's own week only if there is no other
82
+ else if (!week || /all models/i.test(m[2] ?? "")) week = item;
83
+ }
84
+ if (!week?.resets) answer(2, say("unread", { detail: "no line \"Current week … resets …\"" }));
85
+
86
+ const now = Date.now();
87
+ const start = week.resets.getTime() - WEEK;
88
+ const gone = Math.max(0, Math.min(100, ((now - start) / WEEK) * 100));
89
+ const behind = gone - week.used;
90
+ const v = { used: week.used, gone: Math.round(gone), behind: Math.round(behind), min };
91
+ if (session && session.used >= 100) answer(1, say("session"), session.resets ? new Date(session.resets.getTime() + 60_000) : undefined);
92
+ if (behind < min) {
93
+ // when the week gone reaches usage + the number; past the reset, the new week's number
94
+ let at = start + ((week.used + min) / 100) * WEEK;
95
+ if (at >= week.resets.getTime()) at = week.resets.getTime() + (min / 100) * WEEK;
96
+ answer(1, say("week", v), new Date(Math.max(at, now + 5 * 60_000)));
97
+ }
98
+ answer(0, say("go", v));
99
+
100
+ /** "Sep 30 at 2pm (Europe/Prague)", "Oct 2 at 11:30am (UTC)" → the moment. */
101
+ function resetAt(s: string): Date | undefined {
102
+ 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());
103
+ if (!m) return undefined;
104
+ const month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].indexOf(m[1]!.toLowerCase());
105
+ if (month < 0) return undefined;
106
+ const hour = (Number(m[3]) % 12) + (m[5]!.toLowerCase() === "pm" ? 12 : 0);
107
+ const zone = m[6]!;
108
+ for (const year of [new Date().getUTCFullYear(), new Date().getUTCFullYear() + 1]) {
109
+ const guess = Date.UTC(year, month, Number(m[2]), hour, Number(m[4] ?? 0));
110
+ const at = guess - offsetMs(guess, zone);
111
+ if (at > Date.now() - DAY) return new Date(at);
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** The zone's offset from UTC at that moment ("Europe/Prague" in summer: +2 h). */
117
+ function offsetMs(at: number, zone: string): number {
118
+ try {
119
+ const name = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" }).formatToParts(new Date(at)).find((p) => p.type === "timeZoneName")?.value ?? "";
120
+ const m = /GMT([+-])(\d{2}):?(\d{2})?/.exec(name);
121
+ return m ? (m[1] === "-" ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60_000 : 0;
122
+ } catch {
123
+ return 0;
124
+ }
125
+ }
@@ -10,6 +10,7 @@ import { displayName, familiesAsChild, familiesAsPartner, formatName, label, lif
10
10
  import { langName } from "../core/lang.js";
11
11
  import { methodFor } from "../core/assets.js";
12
12
  import { recentSessions } from "../core/session.js";
13
+ import { briefClock } from "../core/clock.js";
13
14
  import { calibrationLine } from "../core/calibration.js";
14
15
  import { taskRecordsets } from "../core/frontier.js";
15
16
  import { reviewItems } from "../core/review.js";
@@ -122,6 +123,7 @@ export function buildBrief(tree, opts) {
122
123
  ...(research?.notes ?? []).slice(-3).map((n) => `From the user: ${n.text}`),
123
124
  `Research language: ${langName(lang)} — talk to the user and write notes, tasks and summaries in ${langName(lang)}; transcripts stay in the original language.`,
124
125
  "Work ONLY through `strom` commands; never edit files in data/ (it is detected and blocks all writing). Record findings as you go.",
126
+ opts.deadline !== undefined ? briefClock(opts.deadline) : "",
125
127
  ].filter(Boolean).join("\n"),
126
128
  });
127
129
  // 2. the task
package/dist/cli/main.js CHANGED
@@ -11,6 +11,8 @@ import { isAgent } from "../core/which.js";
11
11
  import { noticeStromApp } from "../core/stromapp.js";
12
12
  import { isNewer } from "../core/update.js";
13
13
  import { refreshGlobal } from "../agents/global.js";
14
+ import { clockLine } from "../core/clock.js";
15
+ import { currentSession } from "../core/session.js";
14
16
  import { checkArgs, GroupOnly, parseOptions, resolveCommand, splitPassthrough } from "./execute.js";
15
17
  import "../commands/index.js";
16
18
  export { splitCommand } from "./execute.js";
@@ -74,6 +76,9 @@ export async function main(argv, io, env, cwd) {
74
76
  const json = argv.includes("--json");
75
77
  const debug = argv.includes("--debug");
76
78
  resetCache();
79
+ // what ran, for the reminder of the session's time
80
+ let ran;
81
+ let command;
77
82
  try {
78
83
  // "strom 'person list'" (one quoted word) means the same as strom person list.
79
84
  if (argv[0] && !argv[0].startsWith("-") && /\s/.test(argv[0].trim()))
@@ -109,6 +114,8 @@ export async function main(argv, io, env, cwd) {
109
114
  return EXIT.ok;
110
115
  }
111
116
  const ctx = Context.fromOptions({ env, cwd, io, json, values: v });
117
+ ran = ctx;
118
+ command = def.path.join(" ");
112
119
  // Started by the Strom app: remembered quietly (it is where the results go).
113
120
  if (env.STROM_APP)
114
121
  noticeStromApp(ctx.settings, env);
@@ -153,9 +160,27 @@ export async function main(argv, io, env, cwd) {
153
160
  // command has the tree to itself from its first read to its commit.
154
161
  const result = def.writes && def.tree && def.lock !== "sections" ? await ctx.tree().holdTreeLock(work) : await work();
155
162
  print(io, ctx, result);
163
+ remind(io, ctx, command);
156
164
  return result.exitCode ?? EXIT.ok;
157
165
  }
158
166
  catch (err) {
159
- return printError(io, json, err, debug);
167
+ const code = printError(io, json, err, debug);
168
+ remind(io, ran, command);
169
+ return code;
160
170
  }
161
171
  }
172
+ /** Near the end of a session with a time limit (strom run), every command reminds the agent how long it has left. */
173
+ function remind(io, ctx, command) {
174
+ const line = ctx && command !== "session close" ? clockLine(ctx.env) : undefined;
175
+ if (!line)
176
+ return;
177
+ try {
178
+ // closed already: nothing to remind of
179
+ if (ctx.hasTree() && !currentSession(ctx.tree(), ctx.env))
180
+ return;
181
+ }
182
+ catch {
183
+ return;
184
+ }
185
+ io.stderr(line + "\n");
186
+ }
package/dist/cli/menu.js CHANGED
@@ -14,6 +14,7 @@ import { liveHolder } from "../core/lock.js";
14
14
  import { newerVersion } from "../core/update.js";
15
15
  import { taskQueue, waitingForUser } from "../commands/tasks.js";
16
16
  import { humanTask } from "../commands/browse.js";
17
+ import { loadGate } from "../core/gate.js";
17
18
  import { lacksImages } from "../core/queue.js";
18
19
  import { othersAtWork } from "../core/session.js";
19
20
  import { DEFAULT_RUN_MINUTES } from "../core/config.js";
@@ -129,10 +130,15 @@ export async function runMenu(ctx, run) {
129
130
  out(` ${t("ui.plan.more", { n: queue.length - shown.length })}`);
130
131
  }
131
132
  out(t("ui.run.limit", { minutes: ctx.settings.number("run.minutes", fresh.config, DEFAULT_RUN_MINUTES) }));
132
- const how = shown.length ? await ctx.choose(t("ui.run.what"), [{ label: t("ui.run.queue") }, { label: t("ui.run.pick") }], 0, { back: t("ui.browse.back") }) : 0;
133
+ // With a gate of the user's (run.gate): on for as long as there is work and the gate lets it.
134
+ const gate = gateTitle(ctx);
135
+ const ways = [{ label: t("ui.run.queue") }, { label: t("ui.run.pick") }, ...(gate ? [{ label: t("ui.run.loop", { name: gate }) }] : [])];
136
+ const how = shown.length ? await ctx.choose(t("ui.run.what"), ways, 0, { back: t("ui.browse.back") }) : 0;
133
137
  if (how === undefined)
134
138
  return;
135
- if (how === 0) {
139
+ if (how === 2)
140
+ args = ["--loop"];
141
+ else if (how === 0) {
136
142
  let n = -1;
137
143
  while (!(Number.isInteger(n) && n >= 0 && n <= 100)) {
138
144
  if (outOfAnswers())
@@ -280,6 +286,20 @@ export async function runMenu(ctx, run) {
280
286
  }
281
287
  /** How many tasks of the queue the menu lists before the agent works alone. */
282
288
  const RUN_LIST = 15;
289
+ /** The title of the user's gate (run.gate), when there is one to ask. */
290
+ function gateTitle(ctx) {
291
+ const name = ctx.settings.runGate();
292
+ const shared = ctx.settings.shared()?.value;
293
+ if (!name || !shared)
294
+ return undefined;
295
+ try {
296
+ const g = loadGate(shared, name);
297
+ return g.manifest.title ?? g.name;
298
+ }
299
+ catch {
300
+ return name; // the run says what is wrong with it
301
+ }
302
+ }
283
303
  /** "2", "1,4", "2-5", "1 3 6-8" → those numbers (1…max), in the order given, each once; undefined when anything else. */
284
304
  export function pickNumbers(answer, max) {
285
305
  const out = [];
package/dist/cli/ui.js CHANGED
@@ -361,6 +361,7 @@ export const UI = {
361
361
  "ui.stats.tasks": "Tasks: in the queue {queued} · waiting for you {waiting} · done {done}",
362
362
  "ui.stats.sessions": "Sessions of the agent: {n} · the last {last}",
363
363
  "ui.stats.cost": " · cost so far {cost}",
364
+ "ui.stats.cost.partial": " (without sessions stopped before they said what they cost: {n})",
364
365
  "ui.stats.stories": "Stories: {n} (approved {final})",
365
366
  "ui.ped.noparents": "parents not known yet",
366
367
  "ui.ped.nofather": "father not known yet",
@@ -408,6 +409,20 @@ export const UI = {
408
409
  "ui.run.beside": "⚠ An agent is already working on its own here (runs: {n}). Another one beside it takes other tasks — the costs add up.",
409
410
  "ui.run.beside.sure": "Start another one beside it?",
410
411
  "ui.run.others": "⚠ Another run works in this family tree already (since {since}): this one takes other tasks — the costs add up.",
412
+ "ui.run.gate": "· condition: {name} — asked before each session; it may make the run wait",
413
+ "ui.run.gate.wait": "⏸ waiting until {at} — {reason} (Ctrl-C ends the run)",
414
+ "ui.run.stop.gate": "the condition “{name}” said stop — {reason}",
415
+ "ui.run.stop.gate.error": "the condition “{name}” gave no proper answer — {reason} (strom gate test)",
416
+ "ui.run.gate.once": "· condition: {name} — these tasks are yours to start: it is asked once, and if it would not start, you decide",
417
+ "ui.run.gate.anyway": "The condition “{name}” would not start now — {reason}. Start anyway?",
418
+ "ui.run.gate.later": "not yet",
419
+ "ui.run.gate.no": "no",
420
+ "ui.run.gate.noanswer": "no proper answer (strom gate test)",
421
+ "ui.run.stop.gate.declined": "not started — the condition “{name}”: {reason}",
422
+ "ui.run.loop": "Let it work on while there is work and the condition “{name}” allows it",
423
+ "ui.consent.gate.set": "Let the condition “{name}” decide when the agent working alone goes on?",
424
+ "ui.consent.gate.unset": "Remove the condition “{name}” — the agent working alone no longer asks it?",
425
+ "ui.consent.gate.skip": "Let the agent work on its own without the condition “{name}”?",
411
426
  };
412
427
  /** A text for the person, in their language. */
413
428
  export function ui(lang, key, values = {}) {
@@ -112,7 +112,8 @@ register({
112
112
  out.push("", ui(lang, "ui.stats.tasks", s.tasks));
113
113
  if (s.sessions.count)
114
114
  out.push(ui(lang, "ui.stats.sessions", { n: s.sessions.count, last: humanDay(s.sessions.last, lang) }) +
115
- (s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : ""));
115
+ (s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : "") +
116
+ (s.sessions.costUsd && s.sessions.costPartial ? ui(lang, "ui.stats.cost.partial", { n: s.sessions.costPartial }) : ""));
116
117
  if (s.stories.written)
117
118
  out.push(ui(lang, "ui.stats.stories", { n: s.stories.written, final: s.stories.final }));
118
119
  return { text: lines(...out), data: s };
@@ -0,0 +1,58 @@
1
+ // Gates: the user's condition on the agent working alone (core/gate.ts).
2
+ import { register } from "../cli/registry.js";
3
+ import { lines, table } from "../cli/format.js";
4
+ import { StromError } from "../core/errors.js";
5
+ import { askGate, ensureGatesDir, gatesDir, listGates, loadGate } from "../core/gate.js";
6
+ function shared(ctx) {
7
+ const s = ctx.settings.shared()?.value;
8
+ if (!s)
9
+ throw new StromError("strom is not set up yet", { hint: "strom setup" });
10
+ ensureGatesDir(s);
11
+ return s;
12
+ }
13
+ register({
14
+ path: ["gate", "list"],
15
+ summary: "Gates: conditions on the agent working alone (strom run) — which there are, which one is asked",
16
+ group: "research",
17
+ run(ctx) {
18
+ const s = shared(ctx);
19
+ const set = ctx.settings.runGate();
20
+ const setName = set?.split(" ")[0];
21
+ const gates = listGates(s);
22
+ const folder = `folder: ${ctx.display(gatesDir(s))} — copy a gate's folder in to install it (the interface: README.md there)`;
23
+ return {
24
+ text: lines(gates.length ? table(gates.map((g) => [g.name === setName ? `${g.name} ◀ run.gate${set !== setName ? ` (${set})` : ""}` : g.name, g.manifest.title ?? "", g.manifest.command.join(" ")])) : "no gates", set ? `asked before each session of strom run: ${set} (strom config unset run.gate: none)` : "none is asked: strom config set run.gate <name> (only you)", folder),
25
+ data: { folder: gatesDir(s), gate: set ?? null, gates: gates.map((g) => ({ name: g.name, title: g.manifest.title, command: g.manifest.command, dir: g.dir })) },
26
+ };
27
+ },
28
+ }, {
29
+ path: ["gate", "test"],
30
+ summary: "Ask a gate now what it would answer before a session: go on, wait (until when) or stop — and why",
31
+ group: "research",
32
+ args: [{ name: "gate", description: 'the gate and what it is given, e.g. "claude-usage 10" (default: run.gate)' }],
33
+ examples: ["strom gate test", 'strom gate test "claude-usage 10"'],
34
+ run(ctx, { args }) {
35
+ const s = shared(ctx);
36
+ const name = args[0] ?? ctx.settings.runGate();
37
+ if (!name)
38
+ throw new StromError("no gate named and none set", { hint: "strom gate list — strom gate test <name>" });
39
+ const gate = loadGate(s, name);
40
+ const tree = ctx.hasTree() ? ctx.tree() : undefined;
41
+ const agent = ctx.settings.agent(tree?.config).value;
42
+ const said = askGate(gate, ctx.env, {
43
+ tree: tree?.root ?? "",
44
+ lang: tree?.lang ?? ctx.uiLang(),
45
+ agent,
46
+ model: ctx.settings.models(agent, tree?.config).lead,
47
+ sessions: 0,
48
+ costUsd: 0,
49
+ });
50
+ const until = said.verdict === "wait" ? new Date(Date.now() + said.waitMs) : undefined;
51
+ const word = { go: "go on", wait: `wait until ${until?.toLocaleString(ctx.uiLang(), { weekday: "short", day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" })}`, stop: "stop", error: "error — a run would stop" }[said.verdict];
52
+ return {
53
+ text: `${[gate.name, ...gate.args].join(" ")}: ${word}${said.reason ? ` — ${said.reason}` : ""}`,
54
+ data: { gate: gate.name, verdict: said.verdict, ...(said.reason ? { reason: said.reason } : {}), ...(until ? { until: until.toISOString() } : {}) },
55
+ exitCode: said.verdict === "error" ? 1 : 0,
56
+ };
57
+ },
58
+ });
@@ -18,5 +18,6 @@ import "./browse.js";
18
18
  import "./story.js";
19
19
  import "./output.js";
20
20
  import "./session.js";
21
+ import "./gates.js";
21
22
  import "./checks.js";
22
23
  import "./setup.js";
@@ -192,7 +192,11 @@ register({
192
192
  const tasks = created.map((id) => tree.get(id)).filter((t) => !!t);
193
193
  const open = tree.list("task").filter((t) => t.research === research.id && ["open", "doing"].includes(t.state));
194
194
  // what a session costs here, from those so far: the user decides with the price in view
195
- const costs = tree.list("session").map((s) => s.metrics?.costUsd).filter((c) => typeof c === "number");
195
+ const costs = tree
196
+ .list("session")
197
+ .filter((s) => !s.metrics?.costPartial) // stopped before it said: more than it shows
198
+ .map((s) => s.metrics?.costUsd)
199
+ .filter((c) => typeof c === "number");
196
200
  const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : undefined;
197
201
  const list = tree.dryRun ? planned.map((t) => ` would add: ${t.level} · ${t.what}`) : tasks.map((t) => ` ${t.id} ${t.level} · ${t.what}`);
198
202
  const text = lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, "", list.length ? `${label(p)} — ${tree.dryRun ? planned.length : tasks.length} new task(s):` : `${label(p)} — nothing new to review${open.length ? `; ${open.length} task(s) of the review still open` : ""}`, ...list, open.length ? `${open.length} open in ${research.id}, about ${open.length} session(s)${avg !== undefined ? ` — sessions here cost $${avg.toFixed(2)} on average` : ""}` : undefined, open.length ? `next strom run --research ${research.id} (the agent alone) · or in a conversation: strom session start --research ${research.id}` : undefined);