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 +3 -1
- package/assets/lang/cs.json +15 -0
- package/assets/lang/de.json +15 -0
- package/assets/method/core.md +6 -2
- package/assets/method/reading.md +2 -1
- package/assets/plugins/README.md +9 -2
- package/assets/plugins/gates/README.md +95 -0
- package/assets/plugins/gates/claude-usage/gate.json +10 -0
- package/assets/plugins/gates/claude-usage/gate.ts +125 -0
- package/dist/brief/brief.js +2 -0
- package/dist/cli/main.js +26 -1
- package/dist/cli/menu.js +22 -2
- package/dist/cli/ui.js +15 -0
- package/dist/commands/browse.js +2 -1
- package/dist/commands/gates.js +58 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/research.js +5 -1
- package/dist/commands/session.js +152 -15
- package/dist/commands/setup.js +11 -0
- package/dist/commands/sources.js +24 -0
- package/dist/core/clock.js +55 -0
- package/dist/core/config.js +12 -0
- package/dist/core/gate.js +175 -0
- package/dist/core/overview.js +2 -0
- package/dist/core/tree.js +1 -1
- package/dist/runners/antigravity.js +2 -1
- package/dist/runners/claude.js +157 -114
- package/dist/runners/codex.js +14 -1
- package/dist/runners/jsonl.js +63 -37
- package/dist/runners/opencode.js +7 -2
- package/dist/runners/script.js +56 -47
- package/package.json +1 -1
package/dist/commands/session.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { register } from "../cli/registry.js";
|
|
10
10
|
import { ui } from "../cli/ui.js";
|
|
11
11
|
import { lines, table, truncate } from "../cli/format.js";
|
|
12
|
-
import { UsageError, StromError } from "../core/errors.js";
|
|
12
|
+
import { NeedsConsentError, UsageError, StromError } from "../core/errors.js";
|
|
13
13
|
import { closeSession, currentSession, openSessions, othersAtWork, sessionNote, startSession } from "../core/session.js";
|
|
14
14
|
import { buildBrief } from "../brief/brief.js";
|
|
15
15
|
import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
|
|
@@ -25,8 +25,10 @@ import { syncAgentFiles } from "../agents/files.js";
|
|
|
25
25
|
import { setTreeSetting } from "./setup.js";
|
|
26
26
|
import { RUNNERS } from "../runners/index.js";
|
|
27
27
|
import { PROFILES } from "../agents/profiles.js";
|
|
28
|
-
import { AGENTS, detectAgent, which } from "../core/which.js";
|
|
28
|
+
import { AGENTS, detectAgent, isAgent, which } from "../core/which.js";
|
|
29
|
+
import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
|
|
29
30
|
import { keepAwake } from "../core/awake.js";
|
|
31
|
+
import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
|
|
30
32
|
import { stromLauncher } from "../core/self.js";
|
|
31
33
|
import { phrase } from "../core/phrases.js";
|
|
32
34
|
import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
|
|
@@ -161,7 +163,7 @@ register({
|
|
|
161
163
|
tree: true,
|
|
162
164
|
run(ctx) {
|
|
163
165
|
const all = ctx.tree().list("session").slice().reverse();
|
|
164
|
-
const cost = (s) => (s.metrics
|
|
166
|
+
const cost = (s) => costText(s.metrics);
|
|
165
167
|
return {
|
|
166
168
|
text: all.length ? table(all.map((s) => [s.id, s.state, s.task ?? "", s.started.slice(0, 16).replace("T", " "), cost(s), truncate(s.summary ?? "", 70)])) : "no sessions yet → strom session start",
|
|
167
169
|
data: { sessions: all },
|
|
@@ -186,7 +188,7 @@ register({
|
|
|
186
188
|
m.inputTokens && `${m.inputTokens} in`,
|
|
187
189
|
m.outputTokens && `${m.outputTokens} out`,
|
|
188
190
|
m.cacheReadTokens && `${m.cacheReadTokens} cache read`,
|
|
189
|
-
m
|
|
191
|
+
costText(m),
|
|
190
192
|
s.ended && `${Math.max(1, Math.round((Date.parse(s.ended) - Date.parse(s.started)) / 60000))} min`,
|
|
191
193
|
m.denied && `${m.denied} refused by permissions`,
|
|
192
194
|
]
|
|
@@ -210,7 +212,7 @@ register({
|
|
|
210
212
|
const tree = ctx.tree();
|
|
211
213
|
const s = currentSession(tree, ctx.env);
|
|
212
214
|
const task = args[0] ? requireRecord(tree, args[0], "task") : s?.task ? tree.get(s.task) : taskQueue(tree, { strategy: ctx.settings.strategy(tree.config) })[0];
|
|
213
|
-
const b = buildBrief(tree, { ...(task ? { task } : {}), ...(s ? { session: s } : {}), budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
215
|
+
const b = buildBrief(tree, { ...(task ? { task } : {}), ...(s ? { session: s, deadline: deadlineOf(ctx.env) } : {}), budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
|
|
214
216
|
if (opts.stats)
|
|
215
217
|
return {
|
|
216
218
|
text: lines(table(b.sections.map((x) => [x.name, `${x.tokens}`, x.cut ? "cut" : ""])), `total ~${b.total} tokens of ${b.budget}`),
|
|
@@ -381,20 +383,30 @@ register({
|
|
|
381
383
|
description: "One session by default; with --until, session after session until that time. The agent works headless with\n" +
|
|
382
384
|
"the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
|
|
383
385
|
"subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
|
|
384
|
-
"--minutes).
|
|
385
|
-
"
|
|
386
|
-
"
|
|
386
|
+
"--minutes). The agent knows when its session is stopped (the brief; strom's output counts down its last\n" +
|
|
387
|
+
"10 minutes, a short session its last quarter); at --minutes it is stopped, and Claude Code, Codex and OpenCode\n" +
|
|
388
|
+
"get 5 minutes more to write down what they found and close (an unfinished task goes back to the queue); a task\n" +
|
|
389
|
+
"that comes back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
|
|
390
|
+
"one done or held by another agent meanwhile is left out). --loop: session after session for as long as there\n" +
|
|
391
|
+
"is work. A gate (setting run.gate, or --gate; plugins/gates/<name>) is asked before each session of --loop and\n" +
|
|
392
|
+
"--until: go on, wait (the run waits and asks again) or stop — the user's condition, e.g. the subscription's room\n" +
|
|
393
|
+
"(strom gate list). Tasks started otherwise (one, --max, --task) are the user's choice: the gate is asked once,\n" +
|
|
394
|
+
"and when it would not start, the user is asked whether to start anyway (an agent: a window of the system).\n" +
|
|
395
|
+
"Arguments after -- go to the agent CLI unchanged.",
|
|
387
396
|
options: [
|
|
388
397
|
{ name: "task", type: "string", multiple: true, value: "<T…>", description: "work on these tasks, in this order (repeatable or T0003,T0007; default: the next one of the queue)" },
|
|
389
398
|
{ name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
|
|
390
399
|
{ name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
|
|
391
400
|
{ name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
|
|
401
|
+
{ name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
|
|
402
|
+
{ name: "gate", type: "string", value: "<name [n]>", description: 'ask this gate before each session, with what it is given, e.g. "claude-usage 20" (default: run.gate)' },
|
|
403
|
+
{ name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
|
|
392
404
|
{ name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
|
|
393
405
|
{ name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
|
|
394
406
|
{ name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
|
|
395
407
|
{ name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
|
|
396
408
|
],
|
|
397
|
-
examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --task T0003,T0007", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
|
|
409
|
+
examples: ["strom run", "strom run --max 3 --until 23:00", "strom run --loop", "strom run --task T0003,T0007", "strom run --interactive", "strom run --agent codex", "strom run --minutes 30 -- --add-dir ~/Scans"],
|
|
398
410
|
run: async (ctx, { opts, extra }) => {
|
|
399
411
|
const root = ctx.tree().root;
|
|
400
412
|
const treeCfg = ctx.tree().config;
|
|
@@ -408,10 +420,11 @@ register({
|
|
|
408
420
|
const until = parseUntil(opts.until);
|
|
409
421
|
// Tasks the user picked: one session each, in their order (then the queue, when --max asks for more).
|
|
410
422
|
const picked = csvOpt(opts.task).map((id) => requireRecord(Tree.open(root, ctx.env), id, "task").id);
|
|
411
|
-
const max = opts.max === undefined ? (picked.length ? picked.length : until ? Infinity : 1) : Number(opts.max);
|
|
423
|
+
const max = opts.max === undefined ? (picked.length ? picked.length : until || opts.loop ? Infinity : 1) : Number(opts.max);
|
|
412
424
|
if (max !== Infinity && (!Number.isInteger(max) || max < 1))
|
|
413
425
|
throw new UsageError("--max must be a positive number");
|
|
414
426
|
const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
|
|
427
|
+
const gate = runGate(ctx, opts);
|
|
415
428
|
const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
|
|
416
429
|
// Each run has a name of its own (as a conversation does): several work side by side, never on one task.
|
|
417
430
|
const worker = `run-${process.pid}-${Date.now().toString(36)}`;
|
|
@@ -419,10 +432,12 @@ register({
|
|
|
419
432
|
// Present in the tree for the others at work (conversations with agents): strom shows who works.
|
|
420
433
|
const beside = runsAtWork(root);
|
|
421
434
|
const leave = enterWorker(root, worker, `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
|
|
422
|
-
|
|
435
|
+
let stopAwake = keepAwake(ctx.env);
|
|
423
436
|
// Progress goes to stderr when stdout carries JSON.
|
|
424
437
|
const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
|
|
425
438
|
const report = [];
|
|
439
|
+
// What the gate answered, for the record of the run.
|
|
440
|
+
const gates = [];
|
|
426
441
|
// Why the run stopped: a code for the exit status and for data, words for the user (their language).
|
|
427
442
|
const lang = Tree.open(root, runEnv).lang;
|
|
428
443
|
let stopCode = "done";
|
|
@@ -466,6 +481,12 @@ register({
|
|
|
466
481
|
out(ui(lang, "ui.run.browser.model", { model: models.lead }));
|
|
467
482
|
if (permissions === "full")
|
|
468
483
|
out(ui(lang, "ui.run.full"));
|
|
484
|
+
// The gate holds a run that goes on by itself (--loop, --until). Tasks the user starts themselves (one, --max n,
|
|
485
|
+
// --task) are their choice: the gate is asked once, and when it would not start, the user decides.
|
|
486
|
+
const gateHolds = Boolean(opts.loop || until);
|
|
487
|
+
let gateAnswered = false;
|
|
488
|
+
if (gate)
|
|
489
|
+
out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
|
|
469
490
|
for (let i = 0; i < max; i++) {
|
|
470
491
|
if (stop.signal.aborted) {
|
|
471
492
|
stopCode = "user";
|
|
@@ -500,9 +521,49 @@ register({
|
|
|
500
521
|
stopCode = "empty";
|
|
501
522
|
break;
|
|
502
523
|
}
|
|
524
|
+
// The user's condition, between sessions only: go on, wait and ask again, or stop — in a run that goes on by
|
|
525
|
+
// itself. Tasks the user started themselves: asked once, and the user decides when it would not start.
|
|
526
|
+
if (gate && !gateAnswered) {
|
|
527
|
+
const said = askGate(gate, runEnv, { tree: root, lang, agent: runnerId, model: models.lead, sessions: report.length, costUsd: report.reduce((a, r) => a + (r.costUsd ?? 0), 0), nextTask: task.id });
|
|
528
|
+
const answer = { at: new Date().toISOString(), verdict: said.verdict, ...(said.reason ? { reason: said.reason } : {}) };
|
|
529
|
+
gates.push(answer);
|
|
530
|
+
const name = gate.manifest.title ?? gate.name;
|
|
531
|
+
if (!gateHolds) {
|
|
532
|
+
gateAnswered = true;
|
|
533
|
+
if (said.verdict !== "go") {
|
|
534
|
+
if (!(await startAnyway(ctx, name, gateReason(said, lang), lang))) {
|
|
535
|
+
stopCode = "gate.declined";
|
|
536
|
+
stopValues = { name, reason: gateReason(said, lang) };
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
answer.anyway = true;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
else if (said.verdict === "stop" || said.verdict === "error") {
|
|
543
|
+
stopCode = said.verdict === "stop" ? "gate" : "gate.error";
|
|
544
|
+
stopValues = { name, reason: said.reason ?? "" };
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
else if (said.verdict === "wait") {
|
|
548
|
+
const at = Date.now() + said.waitMs;
|
|
549
|
+
if (until && at > until) {
|
|
550
|
+
stopCode = "time";
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
out(ui(lang, "ui.run.gate.wait", { at: new Date(at).toLocaleString(lang, { weekday: "short", hour: "2-digit", minute: "2-digit" }), reason: said.reason ?? "" }));
|
|
554
|
+
// nothing to do meanwhile: the computer may sleep
|
|
555
|
+
stopAwake();
|
|
556
|
+
await pause(at - Date.now(), stop.signal);
|
|
557
|
+
stopAwake = keepAwake(ctx.env);
|
|
558
|
+
i--; // this was no session: ask again, with the queue as it is then
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
503
562
|
const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
|
|
504
563
|
commitNow(tree, `${session.id} session started on ${task.id}`);
|
|
505
|
-
|
|
564
|
+
// When the agent is stopped: it knows (the brief, strom's reminders near the end), and gets a few minutes more to write down what it found.
|
|
565
|
+
const deadline = Date.now() + minutes * 60_000;
|
|
566
|
+
const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
|
|
506
567
|
const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
|
|
507
568
|
fs.mkdirSync(path.dirname(briefFile), { recursive: true });
|
|
508
569
|
fs.writeFileSync(briefFile, brief.text);
|
|
@@ -514,6 +575,8 @@ register({
|
|
|
514
575
|
const env = {
|
|
515
576
|
...prependPath(runEnv, bin),
|
|
516
577
|
STROM_SESSION: session.id,
|
|
578
|
+
STROM_DEADLINE: new Date(deadline).toISOString(),
|
|
579
|
+
STROM_MINUTES: String(minutes),
|
|
517
580
|
...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
|
|
518
581
|
};
|
|
519
582
|
const result = await runner.run({
|
|
@@ -525,6 +588,12 @@ register({
|
|
|
525
588
|
name: ["Strom", tree.config.name, tree.get(task.research ?? research?.id ?? "")?.name, `${truncate(task.what, 50)} (${task.id})`].filter(Boolean).join(" · "),
|
|
526
589
|
logFile: path.join(root, ".strom", "runs", `${session.id}.log`),
|
|
527
590
|
timeoutMs: minutes * 60_000,
|
|
591
|
+
wrapUp: {
|
|
592
|
+
ms: WRAP_UP_MS,
|
|
593
|
+
prompt: `Time is up: strom stopped you at the session's limit (${minutes} min). You have ${WRAP_UP_MS / 60_000} minutes, no more. Do not open anything new: ` +
|
|
594
|
+
"record in strom what you found and have not recorded yet (facts, sources, the images searched, in vain too), " +
|
|
595
|
+
`then \`strom session close --continue --summary "…" --next "exactly where you stopped"\` (or finish the task, if it is done).`,
|
|
596
|
+
},
|
|
528
597
|
settingsFile: path.join(root, ".claude", "settings.json"),
|
|
529
598
|
shared: ctx.settings.shared()?.value,
|
|
530
599
|
...(models.lead ? { model: models.lead } : {}),
|
|
@@ -567,7 +636,7 @@ register({
|
|
|
567
636
|
const geds = writeGedcoms(ctx, after);
|
|
568
637
|
const committed = commitNow(after, `${s.id} ${s.state}: ${truncate(s.summary ?? "", 60)} · ${geds.map((g) => after.relative(g.file)).join(", ")}`);
|
|
569
638
|
report.push({ session: s.id, task: task.id, outcome: result.outcome, ...(s.summary ? { summary: s.summary } : {}), ...(result.metrics.costUsd !== undefined ? { costUsd: result.metrics.costUsd } : {}) });
|
|
570
|
-
out(`■ ${s.id} ${s.state} · ${result.outcome}${result.metrics
|
|
639
|
+
out(`■ ${s.id} ${s.state} · ${result.outcome}${costText(result.metrics) ? ` · ${costText(result.metrics)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
|
|
571
640
|
if (result.denied?.length)
|
|
572
641
|
out(ui(lang, "ui.run.denied", { n: result.denied.length, what: result.denied.slice(0, 3).join(" · ") }));
|
|
573
642
|
if (!committed) {
|
|
@@ -611,11 +680,79 @@ register({
|
|
|
611
680
|
const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
|
|
612
681
|
return {
|
|
613
682
|
text: lines(ui(lang, "ui.run.summary", { n: report.length, reason }), waiting ? `\n${waiting}` : undefined),
|
|
614
|
-
data: { sessions: report, stopped: reason, stop: stopCode },
|
|
615
|
-
exitCode: ["failed", "problems", "auth", "denied"].includes(stopCode) ? 1 : 0,
|
|
683
|
+
data: { sessions: report, stopped: reason, stop: stopCode, ...(gate ? { gate: { name: gate.name, answers: gates } } : {}) },
|
|
684
|
+
exitCode: ["failed", "problems", "auth", "denied", "gate.error"].includes(stopCode) ? 1 : 0,
|
|
616
685
|
};
|
|
617
686
|
},
|
|
618
687
|
});
|
|
688
|
+
/**
|
|
689
|
+
* The gate this run asks (run.gate, or --gate), or none (--no-gate). Going round the user's gate is the user's
|
|
690
|
+
* decision: an agent asks them (a window of the system).
|
|
691
|
+
*/
|
|
692
|
+
function runGate(ctx, opts) {
|
|
693
|
+
const set = ctx.settings.runGate();
|
|
694
|
+
const asked = typeof opts.gate === "string" ? opts.gate : undefined;
|
|
695
|
+
if (opts["no-gate"]) {
|
|
696
|
+
if (set && isAgent(ctx.env))
|
|
697
|
+
ctx.requireHuman(`Let the agent work on its own without the gate "${set}"?`, "strom run --no-gate", "run.gate", ui(ctx.uiLang(), "ui.consent.gate.skip", { name: set }));
|
|
698
|
+
return undefined;
|
|
699
|
+
}
|
|
700
|
+
const name = asked ?? set;
|
|
701
|
+
if (!name)
|
|
702
|
+
return undefined;
|
|
703
|
+
if (asked && set && asked !== set && isAgent(ctx.env))
|
|
704
|
+
ctx.requireHuman(`Ask the gate "${asked}" instead of "${set}"?`, `strom run --gate ${asked}`, "run.gate", ui(ctx.uiLang(), "ui.consent.gate.set", { name: asked }));
|
|
705
|
+
const shared = ctx.settings.shared()?.value;
|
|
706
|
+
if (!shared)
|
|
707
|
+
throw new StromError("no shared folder, so no gates", { hint: "strom setup" });
|
|
708
|
+
ensureGatesDir(shared);
|
|
709
|
+
return loadGate(shared, name);
|
|
710
|
+
}
|
|
711
|
+
/** Why the gate would not start, for the user: its own words, else what it answered. */
|
|
712
|
+
function gateReason(said, lang) {
|
|
713
|
+
return said.reason ?? ui(lang, said.verdict === "error" ? "ui.run.gate.noanswer" : said.verdict === "wait" ? "ui.run.gate.later" : "ui.run.gate.no");
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Tasks the user started themselves while the gate would not start: the user decides — on a terminal, a question;
|
|
717
|
+
* asked by an agent, a window of the system (going round the user's condition is never the agent's decision). A run
|
|
718
|
+
* nobody can ask keeps to the gate.
|
|
719
|
+
*/
|
|
720
|
+
async function startAnyway(ctx, name, reason, lang) {
|
|
721
|
+
const question = ui(lang, "ui.run.gate.anyway", { name, reason });
|
|
722
|
+
if (ctx.interactive && !isAgent(ctx.env))
|
|
723
|
+
return ctx.confirm(question, false);
|
|
724
|
+
if (!isAgent(ctx.env))
|
|
725
|
+
return false;
|
|
726
|
+
try {
|
|
727
|
+
ctx.requireHuman(`Start the tasks although the condition "${name}" would not (${reason})?`, "strom run --no-gate", "run.gate", question);
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
if (err instanceof NeedsConsentError)
|
|
732
|
+
throw err; // nobody to ask: the user runs it themselves
|
|
733
|
+
return false; // the user said no in the window
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/** Wait, until the time or until the user stops the run. */
|
|
737
|
+
function pause(ms, signal) {
|
|
738
|
+
return new Promise((resolve) => {
|
|
739
|
+
if (signal.aborted || ms <= 0)
|
|
740
|
+
return resolve();
|
|
741
|
+
const t = setTimeout(done, ms);
|
|
742
|
+
signal.addEventListener("abort", done, { once: true });
|
|
743
|
+
function done() {
|
|
744
|
+
clearTimeout(t);
|
|
745
|
+
signal.removeEventListener("abort", done);
|
|
746
|
+
resolve();
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/** What a session cost: "$1.20"; "$0.30+" or "cost unknown" when the agent was stopped before it said. */
|
|
751
|
+
function costText(m) {
|
|
752
|
+
if (m?.costPartial)
|
|
753
|
+
return m.costUsd ? `$${m.costUsd.toFixed(2)}+` : "cost unknown";
|
|
754
|
+
return m?.costUsd !== undefined ? `$${m.costUsd.toFixed(2)}` : "";
|
|
755
|
+
}
|
|
619
756
|
/** How the user clears the agent's context, in the agent's own words. */
|
|
620
757
|
const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new" };
|
|
621
758
|
/** The hint after a session in a conversation: the next task in a fresh context (images stay in a context and are paid on every turn). */
|
package/dist/commands/setup.js
CHANGED
|
@@ -30,12 +30,14 @@ import { NeedsInputError, StromError, UsageError } from "../core/errors.js";
|
|
|
30
30
|
import { check } from "../core/check.js";
|
|
31
31
|
import { assertIntact, verifyFull } from "../core/integrity.js";
|
|
32
32
|
import { ensurePluginsDir } from "../core/connector.js";
|
|
33
|
+
import { ensureGatesDir, loadGate } from "../core/gate.js";
|
|
33
34
|
import { downloadsDir } from "../core/browser.js";
|
|
34
35
|
export const SHARED_DIRS = ["media", "catalog", "tools", "cache", "inbox"];
|
|
35
36
|
export function ensureShared(dir) {
|
|
36
37
|
for (const d of SHARED_DIRS)
|
|
37
38
|
fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
38
39
|
ensurePluginsDir(dir);
|
|
40
|
+
ensureGatesDir(dir);
|
|
39
41
|
}
|
|
40
42
|
register({
|
|
41
43
|
path: ["setup"],
|
|
@@ -397,6 +399,15 @@ function setUserSetting(ctx, key, value) {
|
|
|
397
399
|
// Asking before a connector runs is the user's safeguard: only they take it away.
|
|
398
400
|
if (key === "connectors.consent" && value !== "on" && s.connectorsConsent())
|
|
399
401
|
ctx.requireHuman("Let connectors run without asking you first?", `strom config set connectors.consent ${value ?? "off"}`, "connectors.consent", ui(ctx.uiLang(), "ui.consent.connectors.off"));
|
|
402
|
+
// The gate decides what working alone spends: set and taken away by the user alone.
|
|
403
|
+
if (key === "run.gate" && value !== s.runGate())
|
|
404
|
+
ctx.requireHuman(value ? `Let the gate "${value}" decide when the agent working alone goes on?` : `Remove the gate "${s.runGate()}" — the agent working alone no longer asks it?`, value ? `strom config set run.gate ${value}` : "strom config unset run.gate", "run.gate", ui(ctx.uiLang(), value ? "ui.consent.gate.set" : "ui.consent.gate.unset", { name: String(value ?? s.runGate()) }));
|
|
405
|
+
// a gate that is not there (or not a gate) is said now, not at the next run
|
|
406
|
+
const sh = s.shared()?.value;
|
|
407
|
+
if (key === "run.gate" && typeof value === "string" && sh) {
|
|
408
|
+
ensureGatesDir(sh);
|
|
409
|
+
loadGate(sh, value);
|
|
410
|
+
}
|
|
400
411
|
writeStored(s.config, key, s.agent(ctx.hasTree() ? ctx.tree().config : undefined).value, value);
|
|
401
412
|
s.save();
|
|
402
413
|
if (key === "shared" && typeof value === "string")
|
package/dist/commands/sources.js
CHANGED
|
@@ -245,6 +245,23 @@ register({
|
|
|
245
245
|
},
|
|
246
246
|
});
|
|
247
247
|
// ── repositories ───────────────────────────────────────────────────────────
|
|
248
|
+
/** The website's host without "www.", lowercase: https://www.FamilySearch.org/search → familysearch.org. */
|
|
249
|
+
function siteOf(url) {
|
|
250
|
+
if (!url)
|
|
251
|
+
return undefined;
|
|
252
|
+
try {
|
|
253
|
+
return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase().replace(/^www\./, "");
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** An archive already here under this name or on this website. */
|
|
260
|
+
export function sameRepository(all, name, url) {
|
|
261
|
+
const folded = foldText(name);
|
|
262
|
+
const site = siteOf(url);
|
|
263
|
+
return all.find((r) => foldText(r.name) === folded || (site !== undefined && siteOf(r.url) === site));
|
|
264
|
+
}
|
|
248
265
|
register({
|
|
249
266
|
path: ["repo", "add"],
|
|
250
267
|
summary: "Add an archive, library or portal that holds records",
|
|
@@ -260,10 +277,17 @@ register({
|
|
|
260
277
|
{ name: "terms", type: "string", value: "<text>", description: "terms of use in one sentence (+ link)" },
|
|
261
278
|
{ name: "automation", type: "string", value: "<a>", description: "allowed, manual (browser only), forbidden, unknown (default)" },
|
|
262
279
|
{ name: "note", type: "string", value: "<text>", description: "short note" },
|
|
280
|
+
{ name: "another", type: "boolean", description: "a different archive, though one here has its name or website" },
|
|
263
281
|
],
|
|
264
282
|
examples: ['strom repo add "State Archive, online reading room" --country CZ --url https://archive.example.org --automation manual'],
|
|
265
283
|
run(ctx, { args, opts }) {
|
|
266
284
|
const tree = ctx.tree();
|
|
285
|
+
// One archive, one record: a second "FamilySearch" splits its books, terms and lessons in two (found in a live run).
|
|
286
|
+
const twin = opts.another ? undefined : sameRepository(tree.list("repository"), args[0], str(opts.url));
|
|
287
|
+
if (twin)
|
|
288
|
+
throw new UsageError(`${twin.id} "${twin.name}" is this archive already${twin.url ? ` (${twin.url})` : ""}`, {
|
|
289
|
+
hint: `use it: --repo ${twin.id} (strom repo show ${twin.id}) — a different archive after all: add --another`,
|
|
290
|
+
});
|
|
267
291
|
const r = create(tree, "repository", {
|
|
268
292
|
name: args[0].trim(),
|
|
269
293
|
country: str(opts.country)?.toUpperCase(),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The session's time. `strom run` stops an agent at its time limit (run.minutes),
|
|
2
|
+
// and what the agent had only in its context is lost then (found in a live run:
|
|
3
|
+
// 25 minutes of reading a census film, nothing written down, all gone). So the
|
|
4
|
+
// agent is told when its session is stopped (STROM_DEADLINE, set by the run; the
|
|
5
|
+
// brief says it), near the end every strom command it runs reminds it, and at
|
|
6
|
+
// the limit an agent that can be resumed gets a few minutes more to write down
|
|
7
|
+
// what it found and close the session (runners: wrapUp).
|
|
8
|
+
/** From how long before the end strom's output reminds the agent (a short session: its last quarter). */
|
|
9
|
+
export const REMIND_MS = 10 * 60_000;
|
|
10
|
+
/** From how long before the end the reminder says to write down and close (a short session: its last eighth). */
|
|
11
|
+
export const CLOSE_MS = 3 * 60_000;
|
|
12
|
+
/** The time an agent stopped at its limit gets to write down what it found. */
|
|
13
|
+
export const WRAP_UP_MS = 5 * 60_000;
|
|
14
|
+
/** When this session is stopped (ms since the epoch), if a run set a limit. */
|
|
15
|
+
export function deadlineOf(env) {
|
|
16
|
+
const t = Date.parse(env.STROM_DEADLINE ?? "");
|
|
17
|
+
return Number.isNaN(t) ? undefined : t;
|
|
18
|
+
}
|
|
19
|
+
/** The session's whole time limit (STROM_MINUTES), if the run said it. */
|
|
20
|
+
function limitOf(env) {
|
|
21
|
+
const m = Number(env.STROM_MINUTES);
|
|
22
|
+
return m > 0 ? m * 60_000 : undefined;
|
|
23
|
+
}
|
|
24
|
+
/** A time of day as the agent reads it in its brief and in the reminders. */
|
|
25
|
+
export function clockTime(t) {
|
|
26
|
+
return new Date(t).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The line strom adds to a command's output near the end of the session (none before): first that the time is
|
|
30
|
+
* getting short (go on, writing down), in the last minutes to write down and close, then that it is up. A short
|
|
31
|
+
* session is reminded in proportion — a reminder from its first minute would only make the agent give up.
|
|
32
|
+
*/
|
|
33
|
+
export function clockLine(env, now = Date.now()) {
|
|
34
|
+
const end = deadlineOf(env);
|
|
35
|
+
if (end === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
const limit = limitOf(env);
|
|
38
|
+
const remind = limit ? Math.min(REMIND_MS, limit / 4) : REMIND_MS;
|
|
39
|
+
const close = limit ? Math.min(CLOSE_MS, limit / 8) : CLOSE_MS;
|
|
40
|
+
const left = end - now;
|
|
41
|
+
if (left > remind)
|
|
42
|
+
return undefined;
|
|
43
|
+
const min = Math.max(1, Math.ceil(left / 60_000));
|
|
44
|
+
if (left <= 0)
|
|
45
|
+
return '⏳ this session\'s time is up: record what you found and close it now: strom session close --continue --summary "…" --next "exactly where you stopped"';
|
|
46
|
+
if (left <= close)
|
|
47
|
+
return `⏳ ${min} min left: this session is stopped at ${clockTime(end)}. Record what you have found now (facts, sources, the images searched), then close: strom session close --summary "…" --next "…"`;
|
|
48
|
+
return `⏳ ${min} min left: this session is stopped at ${clockTime(end)}. Go on, but write each find down as you have it and start nothing you cannot finish by then.`;
|
|
49
|
+
}
|
|
50
|
+
/** What the brief says of the session's time: when, and not to hurry — strom says when it is getting short. */
|
|
51
|
+
export function briefClock(end, now = Date.now()) {
|
|
52
|
+
return (`Time: this session is stopped at ${clockTime(end)} (it is ${clockTime(now)} now). Do not hurry and do not stop early: strom's output tells you ` +
|
|
53
|
+
"when the time is getting short; until then work on the task as usual. Write each find and each stretch of images searched into strom the " +
|
|
54
|
+
"moment you have it: what is only in your context is lost when the session ends.");
|
|
55
|
+
}
|
package/dist/core/config.js
CHANGED
|
@@ -39,6 +39,8 @@ export const SETTINGS = [
|
|
|
39
39
|
})),
|
|
40
40
|
{ key: "brief.budget", env: "STROM_BRIEF_BUDGET", tree: true, kind: "number", description: `size of the brief in tokens (default ${DEFAULT_BUDGET})` },
|
|
41
41
|
{ key: "run.minutes", env: "STROM_RUN_MINUTES", tree: true, kind: "number", description: `time limit of one \`strom run\` session (default ${DEFAULT_RUN_MINUTES})` },
|
|
42
|
+
// Read from the config file only, changed by the user alone: the gate decides what working alone spends.
|
|
43
|
+
{ key: "run.gate", env: "", tree: false, kind: "plugin", description: "a condition on the agent working alone: the gate (plugins/gates/<name>) strom asks before each session of strom run — go on, wait or stop; its name, then what it is given (strom gate list; e.g. claude-usage 10: the Claude subscription's daily ration, 10 points in hand) — only you set it" },
|
|
42
44
|
{ key: "queue.strategy", env: "STROM_QUEUE_STRATEGY", tree: true, kind: "choice", choices: STRATEGIES, description: "order of the task queue: balanced (default — nearest ancestors first, spread over the lines, nothing taken forever), depth (stay on one line), priority (strict priority)" },
|
|
43
45
|
{ key: "gedcom.for", env: "STROM_GEDCOM_FOR", tree: true, kind: "choice", choices: ["both", "standard", "strom"], description: "GEDCOM files written: both (default), standard (any program), strom (the Strom app)" },
|
|
44
46
|
{ key: "stories", env: "STROM_STORIES", tree: true, kind: "choice", choices: ["yes", "no"], description: "stories of the ancestors for the family, written from the facts: yes (default — strom proposes one once a person's life is told by records), no — the user is told when the research starts and may say no" },
|
|
@@ -73,6 +75,7 @@ const FIELDS = {
|
|
|
73
75
|
"excerpts.quality": "excerptsQuality",
|
|
74
76
|
"excerpts.for": "excerptsFor",
|
|
75
77
|
"excerpts.mb": "excerptsMb",
|
|
78
|
+
"run.gate": "runGate",
|
|
76
79
|
};
|
|
77
80
|
/** Environment variables that are not settings but steer strom. */
|
|
78
81
|
export const OTHER_ENV = [
|
|
@@ -128,6 +131,11 @@ export function checkValue(def, raw, resolvePath) {
|
|
|
128
131
|
throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: def.choices?.join(", ") });
|
|
129
132
|
return c;
|
|
130
133
|
}
|
|
134
|
+
case "plugin":
|
|
135
|
+
// its name, then what it is given: "claude-usage 10"
|
|
136
|
+
if (!/^[a-z0-9][a-z0-9-]*(\s+\S+)*$/.test(v))
|
|
137
|
+
throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: 'a plugin\'s name (lowercase letters, digits and dashes), then what it is given, e.g. "claude-usage 10"' });
|
|
138
|
+
return v.split(/\s+/).join(" ");
|
|
131
139
|
case "person":
|
|
132
140
|
if (!/^[Pp]\d{4,}$/.test(v))
|
|
133
141
|
throw new UsageError(`invalid ${def.key} "${raw}"`, { hint: "a person's ID, e.g. P0009 (strom find <name>)" });
|
|
@@ -332,6 +340,10 @@ export class Settings {
|
|
|
332
340
|
return r ? String(r.value) : undefined;
|
|
333
341
|
}
|
|
334
342
|
/** What the agent may do without asking — from the config file alone, which only the user raises. */
|
|
343
|
+
/** The gate of working alone, if the user set one (the config file only). */
|
|
344
|
+
runGate() {
|
|
345
|
+
return this.config.runGate || undefined;
|
|
346
|
+
}
|
|
335
347
|
agentPermissions() {
|
|
336
348
|
const v = this.config.agentPermissions ?? "auto";
|
|
337
349
|
return PERMISSION_ALIASES[v] ?? (PERMISSION_LEVELS.includes(v) ? v : "auto");
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Gates: a condition the user sets on working alone. Before each session of
|
|
2
|
+
// `strom run` (--loop runs on for as long as there is work) strom asks the
|
|
3
|
+
// gate the user chose (setting run.gate) whether to go on, wait or stop — the
|
|
4
|
+
// subscription's capacity, a budget, the night's tariff: whatever the gate's
|
|
5
|
+
// program decides, strom holds only the interface.
|
|
6
|
+
//
|
|
7
|
+
// A gate is a folder in the plugins folder, <shared>/plugins/gates/<name>/,
|
|
8
|
+
// with gate.json ({"interface": 1, "command": [...]}) and its program; the
|
|
9
|
+
// user may give it arguments after its name (run.gate "claude-usage 10"). The
|
|
10
|
+
// interface (assets/plugins/gates/README.md, copied next to the gates): the
|
|
11
|
+
// program's exit status says it — 0 go on, 1 wait, 2 stop — and one line of
|
|
12
|
+
// JSON on stdout may add why ("reason") and how long to wait ("wait" seconds
|
|
13
|
+
// or "until" a time). Anything else (a crash, no answer in time) stops the
|
|
14
|
+
// run: working alone never spends blind.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { StromError, UsageError } from "./errors.js";
|
|
19
|
+
import { NAME_RE, pluginsDir } from "./connector.js";
|
|
20
|
+
import { readAsset } from "./assets.js";
|
|
21
|
+
export const GATE_INTERFACE = 1;
|
|
22
|
+
const MANIFEST = "gate.json";
|
|
23
|
+
/** How long a gate may think (it may ask a service). */
|
|
24
|
+
const DEFAULT_TIMEOUT_S = 120;
|
|
25
|
+
/** A gate that says wait without saying how long is asked again after this. */
|
|
26
|
+
export const DEFAULT_WAIT_MS = 15 * 60_000;
|
|
27
|
+
/** Never ask again sooner than this (a gate answering "wait 0" must not spin). */
|
|
28
|
+
const MIN_WAIT_MS = 60_000;
|
|
29
|
+
export function gatesDir(shared) {
|
|
30
|
+
return path.join(pluginsDir(shared), "gates");
|
|
31
|
+
}
|
|
32
|
+
/** The gates folder with strom's own files: the interface and the gates strom ships (refreshed). */
|
|
33
|
+
export function ensureGatesDir(shared) {
|
|
34
|
+
const dir = gatesDir(shared);
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
const own = [[path.join(dir, "README.md"), readAsset("plugins", "gates", "README.md")]];
|
|
38
|
+
for (const name of SHIPPED)
|
|
39
|
+
for (const f of ["gate.json", "gate.ts"])
|
|
40
|
+
own.push([path.join(dir, name, f), readAsset("plugins", "gates", name, f)]);
|
|
41
|
+
for (const [file, text] of own) {
|
|
42
|
+
if (text === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
let old;
|
|
45
|
+
try {
|
|
46
|
+
old = fs.readFileSync(file, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
old = undefined;
|
|
50
|
+
}
|
|
51
|
+
if (old === text)
|
|
52
|
+
continue;
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
fs.writeFileSync(file, text);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
// a folder strom may not write here (an agent's sandbox): the gates there still run
|
|
59
|
+
if (!["EACCES", "EPERM", "EROFS"].includes(err?.code ?? ""))
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
/** Gates strom ships: ready in the gates folder, used only when the user sets run.gate. */
|
|
65
|
+
export const SHIPPED = ["claude-usage"];
|
|
66
|
+
export function listGates(shared) {
|
|
67
|
+
const dir = gatesDir(shared);
|
|
68
|
+
let names = [];
|
|
69
|
+
try {
|
|
70
|
+
names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && NAME_RE.test(d.name)).map((d) => d.name);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const name of names.sort()) {
|
|
77
|
+
try {
|
|
78
|
+
out.push(loadGate(shared, name));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// not a gate (yet): no gate.json, or one strom cannot read — `strom gate test <name>` says why
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/** A gate as the user names it: its name, then what it is given ("claude-usage 10"). */
|
|
87
|
+
export function loadGate(shared, spec) {
|
|
88
|
+
const [name = "", ...args] = spec.trim().split(/\s+/);
|
|
89
|
+
if (!NAME_RE.test(name))
|
|
90
|
+
throw new UsageError(`invalid gate name "${name}"`, { hint: "lowercase letters, digits and dashes, e.g. claude-usage" });
|
|
91
|
+
const dir = path.join(gatesDir(shared), name);
|
|
92
|
+
const file = path.join(dir, MANIFEST);
|
|
93
|
+
if (!fs.existsSync(file))
|
|
94
|
+
throw new StromError(`no gate "${name}" (no ${file})`, { hint: `the gates here: strom gate list — a gate is a folder in ${gatesDir(shared)} with ${MANIFEST}` });
|
|
95
|
+
let m;
|
|
96
|
+
try {
|
|
97
|
+
m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new StromError(`gate "${name}": ${MANIFEST} is not valid JSON (${err.message})`);
|
|
101
|
+
}
|
|
102
|
+
if (m.interface !== GATE_INTERFACE)
|
|
103
|
+
throw new StromError(`gate "${name}" is for interface ${m.interface}, this strom knows ${GATE_INTERFACE}`, { hint: m.interface > GATE_INTERFACE ? "update strom: strom update" : `see ${path.join(gatesDir(shared), "README.md")}` });
|
|
104
|
+
if (!Array.isArray(m.command) || !m.command.length || !m.command.every((c) => typeof c === "string" && c))
|
|
105
|
+
throw new StromError(`gate "${name}": "command" must be a list of strings, e.g. ["node", "gate.ts"]`);
|
|
106
|
+
return { name, dir, manifest: m, args };
|
|
107
|
+
}
|
|
108
|
+
/** Ask the gate once. Never throws: a gate that fails answers "error". */
|
|
109
|
+
export function askGate(gate, env, facts) {
|
|
110
|
+
const [cmd, ...args] = gate.manifest.command;
|
|
111
|
+
const program = cmd === "node" ? process.execPath : cmd;
|
|
112
|
+
const timeout = (gate.manifest.timeout ?? DEFAULT_TIMEOUT_S) * 1000;
|
|
113
|
+
const r = spawnSync(program, [...args, ...gate.args], {
|
|
114
|
+
cwd: gate.dir,
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
timeout,
|
|
117
|
+
windowsHide: true,
|
|
118
|
+
env: {
|
|
119
|
+
...env,
|
|
120
|
+
STROM_GATE: gate.name,
|
|
121
|
+
STROM_TREE: facts.tree,
|
|
122
|
+
STROM_LANG: facts.lang,
|
|
123
|
+
STROM_AGENT: facts.agent,
|
|
124
|
+
STROM_MODEL: facts.model ?? "",
|
|
125
|
+
STROM_SESSIONS: String(facts.sessions),
|
|
126
|
+
STROM_COST_USD: facts.costUsd.toFixed(2),
|
|
127
|
+
STROM_NEXT_TASK: facts.nextTask ?? "",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
if (r.error) {
|
|
131
|
+
const timedOut = r.error.code === "ETIMEDOUT";
|
|
132
|
+
return { verdict: "error", reason: timedOut ? `no answer within ${timeout / 1000} s` : r.error.message };
|
|
133
|
+
}
|
|
134
|
+
const said = parseSaid(r.stdout ?? "");
|
|
135
|
+
const reason = said.reason ?? (r.status !== 0 && r.status !== 1 && r.status !== 2 ? lastLine(r.stderr ?? "") : undefined);
|
|
136
|
+
switch (r.status) {
|
|
137
|
+
case 0:
|
|
138
|
+
return { verdict: "go", ...(reason ? { reason } : {}) };
|
|
139
|
+
case 1:
|
|
140
|
+
return { verdict: "wait", ...(reason ? { reason } : {}), waitMs: Math.max(MIN_WAIT_MS, said.waitMs ?? DEFAULT_WAIT_MS) };
|
|
141
|
+
case 2:
|
|
142
|
+
return { verdict: "stop", ...(reason ? { reason } : {}) };
|
|
143
|
+
default:
|
|
144
|
+
return { verdict: "error", reason: reason ?? (r.signal ? `ended by ${r.signal}` : `exit status ${r.status}`) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** The last line of JSON the gate printed: {"reason", "wait" (seconds) | "until" (a time)}; plain text is the reason. */
|
|
148
|
+
function parseSaid(stdout) {
|
|
149
|
+
const line = lastLine(stdout);
|
|
150
|
+
if (!line)
|
|
151
|
+
return {};
|
|
152
|
+
if (!line.startsWith("{"))
|
|
153
|
+
return { reason: line.slice(0, 300) };
|
|
154
|
+
try {
|
|
155
|
+
const j = JSON.parse(line);
|
|
156
|
+
const out = {};
|
|
157
|
+
if (typeof j.reason === "string" && j.reason.trim())
|
|
158
|
+
out.reason = j.reason.trim().slice(0, 300);
|
|
159
|
+
if (typeof j.wait === "number" && Number.isFinite(j.wait) && j.wait >= 0)
|
|
160
|
+
out.waitMs = j.wait * 1000;
|
|
161
|
+
else if (typeof j.until === "string" && !Number.isNaN(Date.parse(j.until)))
|
|
162
|
+
out.waitMs = Date.parse(j.until) - Date.now();
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return { reason: line.slice(0, 300) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function lastLine(s) {
|
|
170
|
+
return s
|
|
171
|
+
.split(/\r?\n/)
|
|
172
|
+
.map((l) => l.trim())
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.pop();
|
|
175
|
+
}
|
package/dist/core/overview.js
CHANGED
|
@@ -72,6 +72,8 @@ export function treeStats(tree, from) {
|
|
|
72
72
|
if (!stats.sessions.last || at > stats.sessions.last)
|
|
73
73
|
stats.sessions.last = at;
|
|
74
74
|
cost += s.metrics?.costUsd ?? 0;
|
|
75
|
+
if (s.metrics?.costPartial)
|
|
76
|
+
stats.sessions.costPartial = (stats.sessions.costPartial ?? 0) + 1;
|
|
75
77
|
}
|
|
76
78
|
if (cost > 0)
|
|
77
79
|
stats.sessions.costUsd = Math.round(cost * 100) / 100;
|