strom-research 1.1.1 → 1.3.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.
Files changed (50) hide show
  1. package/README.md +6 -1
  2. package/assets/lang/cs.json +177 -6
  3. package/assets/lang/de.json +177 -6
  4. package/assets/method/core.md +6 -2
  5. package/assets/method/reading.md +2 -1
  6. package/assets/plugins/README.md +16 -2
  7. package/assets/plugins/gates/README.md +95 -0
  8. package/assets/plugins/gates/claude-usage/gate.json +10 -0
  9. package/assets/plugins/gates/claude-usage/gate.ts +125 -0
  10. package/assets/plugins/hooks/README.md +91 -0
  11. package/dist/agents/global.js +10 -0
  12. package/dist/agents/launch.js +1 -1
  13. package/dist/brief/brief.js +2 -0
  14. package/dist/cli/main.js +43 -2
  15. package/dist/cli/menu-parts.js +140 -0
  16. package/dist/cli/menu-research.js +194 -0
  17. package/dist/cli/menu-settings.js +273 -0
  18. package/dist/cli/menu-waiting.js +79 -0
  19. package/dist/cli/menu.js +113 -99
  20. package/dist/cli/ui.js +175 -5
  21. package/dist/cli/wizard.js +48 -2
  22. package/dist/commands/browse.js +4 -3
  23. package/dist/commands/connectors.js +25 -2
  24. package/dist/commands/gates.js +58 -0
  25. package/dist/commands/hooks.js +141 -0
  26. package/dist/commands/index.js +2 -0
  27. package/dist/commands/intake.js +2 -2
  28. package/dist/commands/research.js +60 -17
  29. package/dist/commands/session.js +161 -18
  30. package/dist/commands/setup.js +57 -1
  31. package/dist/commands/sources.js +24 -0
  32. package/dist/commands/start.js +5 -1
  33. package/dist/commands/tasks.js +41 -4
  34. package/dist/core/browser.js +50 -0
  35. package/dist/core/clock.js +55 -0
  36. package/dist/core/config.js +18 -0
  37. package/dist/core/gate.js +175 -0
  38. package/dist/core/hooks.js +210 -0
  39. package/dist/core/media.js +21 -7
  40. package/dist/core/overview.js +2 -0
  41. package/dist/core/phrases.js +1 -0
  42. package/dist/core/relocate.js +111 -0
  43. package/dist/core/tree.js +19 -2
  44. package/dist/runners/antigravity.js +2 -1
  45. package/dist/runners/claude.js +160 -114
  46. package/dist/runners/codex.js +14 -1
  47. package/dist/runners/jsonl.js +63 -37
  48. package/dist/runners/opencode.js +7 -2
  49. package/dist/runners/script.js +56 -47
  50. package/package.json +1 -1
@@ -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";
@@ -35,6 +37,7 @@ import { guard } from "../core/guard.js";
35
37
  import { hasErrors } from "../core/check.js";
36
38
  import { Tree } from "../core/tree.js";
37
39
  import { treeBrowserConnectors } from "../core/connector.js";
40
+ import { browserNote } from "./connectors.js";
38
41
  import { reviewProposals } from "../core/review.js";
39
42
  function written(tree) {
40
43
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
@@ -161,7 +164,7 @@ register({
161
164
  tree: true,
162
165
  run(ctx) {
163
166
  const all = ctx.tree().list("session").slice().reverse();
164
- const cost = (s) => (s.metrics?.costUsd !== undefined ? `$${s.metrics.costUsd.toFixed(2)}` : "");
167
+ const cost = (s) => costText(s.metrics);
165
168
  return {
166
169
  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
170
  data: { sessions: all },
@@ -186,7 +189,7 @@ register({
186
189
  m.inputTokens && `${m.inputTokens} in`,
187
190
  m.outputTokens && `${m.outputTokens} out`,
188
191
  m.cacheReadTokens && `${m.cacheReadTokens} cache read`,
189
- m.costUsd !== undefined && `$${m.costUsd.toFixed(2)}`,
192
+ costText(m),
190
193
  s.ended && `${Math.max(1, Math.round((Date.parse(s.ended) - Date.parse(s.started)) / 60000))} min`,
191
194
  m.denied && `${m.denied} refused by permissions`,
192
195
  ]
@@ -210,7 +213,7 @@ register({
210
213
  const tree = ctx.tree();
211
214
  const s = currentSession(tree, ctx.env);
212
215
  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 });
216
+ 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
217
  if (opts.stats)
215
218
  return {
216
219
  text: lines(table(b.sections.map((x) => [x.name, `${x.tokens}`, x.cut ? "cut" : ""])), `total ~${b.total} tokens of ${b.budget}`),
@@ -381,20 +384,30 @@ register({
381
384
  description: "One session by default; with --until, session after session until that time. The agent works headless with\n" +
382
385
  "the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
383
386
  "subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
384
- "--minutes). A session longer than --minutes is stopped (its task goes back to the queue); a task that comes\n" +
385
- "back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
386
- "one done or held by another agent meanwhile is left out). Arguments after -- go to the agent CLI unchanged.",
387
+ "--minutes). The agent knows when its session is stopped (the brief; strom's output counts down its last\n" +
388
+ "10 minutes, a short session its last quarter); at --minutes it is stopped, and Claude Code, Codex and OpenCode\n" +
389
+ "get 5 minutes more to write down what they found and close (an unfinished task goes back to the queue); a task\n" +
390
+ "that comes back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
391
+ "one done or held by another agent meanwhile is left out). --loop: session after session for as long as there\n" +
392
+ "is work. A gate (setting run.gate, or --gate; plugins/gates/<name>) is asked before each session of --loop and\n" +
393
+ "--until: go on, wait (the run waits and asks again) or stop — the user's condition, e.g. the subscription's room\n" +
394
+ "(strom gate list). Tasks started otherwise (one, --max, --task) are the user's choice: the gate is asked once,\n" +
395
+ "and when it would not start, the user is asked whether to start anyway (an agent: a window of the system).\n" +
396
+ "Arguments after -- go to the agent CLI unchanged.",
387
397
  options: [
388
398
  { 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
399
  { name: "research", type: "string", value: "<G…>", description: "only tasks of this research" },
390
400
  { name: "max", type: "string", value: "<n>", description: "at most n sessions (default 1; with --until, no limit)" },
391
401
  { name: "until", type: "string", value: "<HH:MM>", description: "start no session after this time" },
402
+ { name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
403
+ { 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)' },
404
+ { name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
392
405
  { name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
393
406
  { name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
394
407
  { name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
395
408
  { name: "interactive", type: "boolean", description: "give the agent the terminal (you can talk to it)" },
396
409
  ],
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"],
410
+ 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
411
  run: async (ctx, { opts, extra }) => {
399
412
  const root = ctx.tree().root;
400
413
  const treeCfg = ctx.tree().config;
@@ -408,10 +421,11 @@ register({
408
421
  const until = parseUntil(opts.until);
409
422
  // Tasks the user picked: one session each, in their order (then the queue, when --max asks for more).
410
423
  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);
424
+ const max = opts.max === undefined ? (picked.length ? picked.length : until || opts.loop ? Infinity : 1) : Number(opts.max);
412
425
  if (max !== Infinity && (!Number.isInteger(max) || max < 1))
413
426
  throw new UsageError("--max must be a positive number");
414
427
  const minutes = ctx.settings.number("run.minutes", treeCfg, DEFAULT_RUN_MINUTES);
428
+ const gate = runGate(ctx, opts);
415
429
  const budget = ctx.settings.number("brief.budget", treeCfg, DEFAULT_BUDGET);
416
430
  // Each run has a name of its own (as a conversation does): several work side by side, never on one task.
417
431
  const worker = `run-${process.pid}-${Date.now().toString(36)}`;
@@ -419,10 +433,12 @@ register({
419
433
  // Present in the tree for the others at work (conversations with agents): strom shows who works.
420
434
  const beside = runsAtWork(root);
421
435
  const leave = enterWorker(root, worker, `${PROFILES[runnerId]?.name ?? runnerId} on its own`);
422
- const stopAwake = keepAwake(ctx.env);
436
+ let stopAwake = keepAwake(ctx.env);
423
437
  // Progress goes to stderr when stdout carries JSON.
424
438
  const out = (s) => (ctx.json ? ctx.io.stderr : ctx.io.stdout)(s + "\n");
425
439
  const report = [];
440
+ // What the gate answered, for the record of the run.
441
+ const gates = [];
426
442
  // Why the run stopped: a code for the exit status and for data, words for the user (their language).
427
443
  const lang = Tree.open(root, runEnv).lang;
428
444
  let stopCode = "done";
@@ -464,8 +480,18 @@ register({
464
480
  out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
465
481
  if (browser.length && /haiku/i.test(models.lead ?? ""))
466
482
  out(ui(lang, "ui.run.browser.model", { model: models.lead }));
483
+ // Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
484
+ const browserSays = browserNote(ctx, Tree.open(root, runEnv), runnerId);
485
+ if (browserSays)
486
+ out(browserSays);
467
487
  if (permissions === "full")
468
488
  out(ui(lang, "ui.run.full"));
489
+ // The gate holds a run that goes on by itself (--loop, --until). Tasks the user starts themselves (one, --max n,
490
+ // --task) are their choice: the gate is asked once, and when it would not start, the user decides.
491
+ const gateHolds = Boolean(opts.loop || until);
492
+ let gateAnswered = false;
493
+ if (gate)
494
+ out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
469
495
  for (let i = 0; i < max; i++) {
470
496
  if (stop.signal.aborted) {
471
497
  stopCode = "user";
@@ -500,9 +526,49 @@ register({
500
526
  stopCode = "empty";
501
527
  break;
502
528
  }
529
+ // The user's condition, between sessions only: go on, wait and ask again, or stop — in a run that goes on by
530
+ // itself. Tasks the user started themselves: asked once, and the user decides when it would not start.
531
+ if (gate && !gateAnswered) {
532
+ 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 });
533
+ const answer = { at: new Date().toISOString(), verdict: said.verdict, ...(said.reason ? { reason: said.reason } : {}) };
534
+ gates.push(answer);
535
+ const name = gate.manifest.title ?? gate.name;
536
+ if (!gateHolds) {
537
+ gateAnswered = true;
538
+ if (said.verdict !== "go") {
539
+ if (!(await startAnyway(ctx, name, gateReason(said, lang), lang))) {
540
+ stopCode = "gate.declined";
541
+ stopValues = { name, reason: gateReason(said, lang) };
542
+ break;
543
+ }
544
+ answer.anyway = true;
545
+ }
546
+ }
547
+ else if (said.verdict === "stop" || said.verdict === "error") {
548
+ stopCode = said.verdict === "stop" ? "gate" : "gate.error";
549
+ stopValues = { name, reason: said.reason ?? "" };
550
+ break;
551
+ }
552
+ else if (said.verdict === "wait") {
553
+ const at = Date.now() + said.waitMs;
554
+ if (until && at > until) {
555
+ stopCode = "time";
556
+ break;
557
+ }
558
+ out(ui(lang, "ui.run.gate.wait", { at: new Date(at).toLocaleString(lang, { weekday: "short", hour: "2-digit", minute: "2-digit" }), reason: said.reason ?? "" }));
559
+ // nothing to do meanwhile: the computer may sleep
560
+ stopAwake();
561
+ await pause(at - Date.now(), stop.signal);
562
+ stopAwake = keepAwake(ctx.env);
563
+ i--; // this was no session: ask again, with the queue as it is then
564
+ continue;
565
+ }
566
+ }
503
567
  const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
504
568
  commitNow(tree, `${session.id} session started on ${task.id}`);
505
- const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value });
569
+ // 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.
570
+ const deadline = Date.now() + minutes * 60_000;
571
+ const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
506
572
  const briefFile = path.join(root, ".strom", "briefs", `${session.id}.md`);
507
573
  fs.mkdirSync(path.dirname(briefFile), { recursive: true });
508
574
  fs.writeFileSync(briefFile, brief.text);
@@ -514,6 +580,8 @@ register({
514
580
  const env = {
515
581
  ...prependPath(runEnv, bin),
516
582
  STROM_SESSION: session.id,
583
+ STROM_DEADLINE: new Date(deadline).toISOString(),
584
+ STROM_MINUTES: String(minutes),
517
585
  ...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
518
586
  };
519
587
  const result = await runner.run({
@@ -525,11 +593,17 @@ register({
525
593
  name: ["Strom", tree.config.name, tree.get(task.research ?? research?.id ?? "")?.name, `${truncate(task.what, 50)} (${task.id})`].filter(Boolean).join(" · "),
526
594
  logFile: path.join(root, ".strom", "runs", `${session.id}.log`),
527
595
  timeoutMs: minutes * 60_000,
596
+ wrapUp: {
597
+ ms: WRAP_UP_MS,
598
+ 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: ` +
599
+ "record in strom what you found and have not recorded yet (facts, sources, the images searched, in vain too), " +
600
+ `then \`strom session close --continue --summary "…" --next "exactly where you stopped"\` (or finish the task, if it is done).`,
601
+ },
528
602
  settingsFile: path.join(root, ".claude", "settings.json"),
529
603
  shared: ctx.settings.shared()?.value,
530
604
  ...(models.lead ? { model: models.lead } : {}),
531
605
  ...(opts.interactive ? { interactive: true } : {}),
532
- ...(runnerId === "claude" ? { chrome: browser.length > 0, permissions } : {}),
606
+ ...(runnerId === "claude" ? { chrome: browser.length > 0, permissions, remote: ctx.settings.agentRemote() } : {}),
533
607
  ...(extra?.length ? { extraArgs: extra } : {}),
534
608
  onProgress: (l) => out(` · ${l}`),
535
609
  signal: stop.signal,
@@ -555,10 +629,11 @@ register({
555
629
  const back = after.get(task.id);
556
630
  const idle = back && back.state === "open" ? idleSessions(after, task.id) : 0;
557
631
  if (idle >= MAX_IDLE_SESSIONS) {
558
- update(after, task.id, "task", (t) => ({ ...t, state: "parked" }), {
632
+ const why = `${idle} sessions in a row recorded nothing for it — needs a look (strom task show ${task.id})`;
633
+ update(after, task.id, "task", (t) => ({ ...t, state: "parked", parkedReason: why }), {
559
634
  op: "task.park",
560
635
  summary: `${task.id} park: ${idle} sessions without a result`,
561
- reason: `${idle} sessions in a row recorded nothing for it — needs a look (strom task show ${task.id})`,
636
+ reason: why,
562
637
  });
563
638
  out(ui(lang, "ui.run.parked", { task: task.id, n: idle }));
564
639
  }
@@ -567,7 +642,7 @@ register({
567
642
  const geds = writeGedcoms(ctx, after);
568
643
  const committed = commitNow(after, `${s.id} ${s.state}: ${truncate(s.summary ?? "", 60)} · ${geds.map((g) => after.relative(g.file)).join(", ")}`);
569
644
  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.costUsd !== undefined ? ` · $${result.metrics.costUsd.toFixed(2)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
645
+ out(`■ ${s.id} ${s.state} · ${result.outcome}${costText(result.metrics) ? ` · ${costText(result.metrics)}` : ""}${s.summary ? ` · ${truncate(s.summary, 80)}` : ""}`);
571
646
  if (result.denied?.length)
572
647
  out(ui(lang, "ui.run.denied", { n: result.denied.length, what: result.denied.slice(0, 3).join(" · ") }));
573
648
  if (!committed) {
@@ -611,11 +686,79 @@ register({
611
686
  const reason = ui(lang, `ui.run.stop.${stopCode}`, stopValues);
612
687
  return {
613
688
  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,
689
+ data: { sessions: report, stopped: reason, stop: stopCode, ...(gate ? { gate: { name: gate.name, answers: gates } } : {}) },
690
+ exitCode: ["failed", "problems", "auth", "denied", "gate.error"].includes(stopCode) ? 1 : 0,
616
691
  };
617
692
  },
618
693
  });
694
+ /**
695
+ * The gate this run asks (run.gate, or --gate), or none (--no-gate). Going round the user's gate is the user's
696
+ * decision: an agent asks them (a window of the system).
697
+ */
698
+ function runGate(ctx, opts) {
699
+ const set = ctx.settings.runGate();
700
+ const asked = typeof opts.gate === "string" ? opts.gate : undefined;
701
+ if (opts["no-gate"]) {
702
+ if (set && isAgent(ctx.env))
703
+ 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 }));
704
+ return undefined;
705
+ }
706
+ const name = asked ?? set;
707
+ if (!name)
708
+ return undefined;
709
+ if (asked && set && asked !== set && isAgent(ctx.env))
710
+ ctx.requireHuman(`Ask the gate "${asked}" instead of "${set}"?`, `strom run --gate ${asked}`, "run.gate", ui(ctx.uiLang(), "ui.consent.gate.set", { name: asked }));
711
+ const shared = ctx.settings.shared()?.value;
712
+ if (!shared)
713
+ throw new StromError("no shared folder, so no gates", { hint: "strom setup" });
714
+ ensureGatesDir(shared);
715
+ return loadGate(shared, name);
716
+ }
717
+ /** Why the gate would not start, for the user: its own words, else what it answered. */
718
+ function gateReason(said, lang) {
719
+ return said.reason ?? ui(lang, said.verdict === "error" ? "ui.run.gate.noanswer" : said.verdict === "wait" ? "ui.run.gate.later" : "ui.run.gate.no");
720
+ }
721
+ /**
722
+ * Tasks the user started themselves while the gate would not start: the user decides — on a terminal, a question;
723
+ * asked by an agent, a window of the system (going round the user's condition is never the agent's decision). A run
724
+ * nobody can ask keeps to the gate.
725
+ */
726
+ async function startAnyway(ctx, name, reason, lang) {
727
+ const question = ui(lang, "ui.run.gate.anyway", { name, reason });
728
+ if (ctx.interactive && !isAgent(ctx.env))
729
+ return ctx.confirm(question, false);
730
+ if (!isAgent(ctx.env))
731
+ return false;
732
+ try {
733
+ ctx.requireHuman(`Start the tasks although the condition "${name}" would not (${reason})?`, "strom run --no-gate", "run.gate", question);
734
+ return true;
735
+ }
736
+ catch (err) {
737
+ if (err instanceof NeedsConsentError)
738
+ throw err; // nobody to ask: the user runs it themselves
739
+ return false; // the user said no in the window
740
+ }
741
+ }
742
+ /** Wait, until the time or until the user stops the run. */
743
+ function pause(ms, signal) {
744
+ return new Promise((resolve) => {
745
+ if (signal.aborted || ms <= 0)
746
+ return resolve();
747
+ const t = setTimeout(done, ms);
748
+ signal.addEventListener("abort", done, { once: true });
749
+ function done() {
750
+ clearTimeout(t);
751
+ signal.removeEventListener("abort", done);
752
+ resolve();
753
+ }
754
+ });
755
+ }
756
+ /** What a session cost: "$1.20"; "$0.30+" or "cost unknown" when the agent was stopped before it said. */
757
+ function costText(m) {
758
+ if (m?.costPartial)
759
+ return m.costUsd ? `$${m.costUsd.toFixed(2)}+` : "cost unknown";
760
+ return m?.costUsd !== undefined ? `$${m.costUsd.toFixed(2)}` : "";
761
+ }
619
762
  /** How the user clears the agent's context, in the agent's own words. */
620
763
  const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new" };
621
764
  /** 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). */
@@ -19,6 +19,7 @@ import { installation, stromLauncher } from "../core/self.js";
19
19
  import { VERSION } from "../core/tree.js";
20
20
  import { installUpdate, isNewer, knownNewerVersion, latestVersion, newerNode, newerVersion } from "../core/update.js";
21
21
  import { desktopDir } from "../core/paths.js";
22
+ import { planMove } from "../core/relocate.js";
22
23
  import { researchUrl, stromAppUrl, stromAppState } from "../core/stromapp.js";
23
24
  import { isInstalled } from "../agents/global.js";
24
25
  import { shortcutName } from "../cli/wizard.js";
@@ -30,12 +31,17 @@ import { NeedsInputError, StromError, UsageError } from "../core/errors.js";
30
31
  import { check } from "../core/check.js";
31
32
  import { assertIntact, verifyFull } from "../core/integrity.js";
32
33
  import { ensurePluginsDir } from "../core/connector.js";
33
- import { downloadsDir } from "../core/browser.js";
34
+ import { ensureGatesDir, loadGate } from "../core/gate.js";
35
+ import { ensureHooksDir } from "../core/hooks.js";
36
+ import { claudeInChrome, CLAUDE_IN_CHROME_URL, downloadsDir } from "../core/browser.js";
37
+ import { browserConnectors } from "../core/connector.js";
34
38
  export const SHARED_DIRS = ["media", "catalog", "tools", "cache", "inbox"];
35
39
  export function ensureShared(dir) {
36
40
  for (const d of SHARED_DIRS)
37
41
  fs.mkdirSync(path.join(dir, d), { recursive: true });
38
42
  ensurePluginsDir(dir);
43
+ ensureGatesDir(dir);
44
+ ensureHooksDir(dir);
39
45
  }
40
46
  register({
41
47
  path: ["setup"],
@@ -67,6 +73,12 @@ register({
67
73
  set: `strom setup --home "${ctx.display(suggestedHome)}" --yes`,
68
74
  },
69
75
  ]);
76
+ if (cfg.home && flags.home)
77
+ guardResearchFolder(ctx, "home", flags.home);
78
+ if (flags.shared)
79
+ guardResearchFolder(ctx, "shared", flags.shared);
80
+ if (flags.trees)
81
+ guardResearchFolder(ctx, "trees", flags.trees);
70
82
  let home = flags.home ? ctx.resolvePath(flags.home) : suggestedHome;
71
83
  let shared = flags.shared ? ctx.resolvePath(flags.shared) : undefined;
72
84
  let trees = flags.trees ? ctx.resolvePath(flags.trees) : undefined;
@@ -187,6 +199,19 @@ function diagnose(ctx) {
187
199
  if (model)
188
200
  add("model", "ok", model);
189
201
  }
202
+ // Archives through the browser: only Claude Code has browser tools, and they work through the Claude in Chrome extension.
203
+ const shared = ctx.settings.shared()?.value;
204
+ const viaBrowser = shared ? browserConnectors(ctx.env, shared) : [];
205
+ if (viaBrowser.length) {
206
+ const names = viaBrowser.map((c) => c.manifest.title ?? c.name).join(", ");
207
+ const ext = claudeInChrome(ctx.env).extension;
208
+ if (chosen !== "claude")
209
+ add("browser", "warn", t("ui.doc.browser.agent", { names, agent: PROFILES[chosen]?.name ?? chosen }), found.includes("claude") ? "strom agents use claude" : undefined);
210
+ else if (ext.length)
211
+ add("browser", "ok", t("ui.doc.browser.ok", { browsers: ext.join(", ") }));
212
+ else
213
+ add("browser", "warn", t("ui.doc.browser.noext", { names }), CLAUDE_IN_CHROME_URL);
214
+ }
190
215
  // For a person: the shortcut on the desktop, the Strom app.
191
216
  const desktop = desktopDir(ctx.env);
192
217
  const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.lnk`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
@@ -389,14 +414,45 @@ function raises(now, value) {
389
414
  return PERMISSION_LEVELS.indexOf(next) > PERMISSION_LEVELS.indexOf(now);
390
415
  }
391
416
  /** Change a user setting (the config file on this computer). */
417
+ /**
418
+ * Where the research lives (home, trees, shared) changes only with the research moving along — which the person does in
419
+ * the wizard (strom setup in a terminal, the menu's settings). A folder set to another place with the research left in
420
+ * the old one would lose the trees from the list and the plugins and images from sight.
421
+ */
422
+ function guardResearchFolder(ctx, key, next) {
423
+ const now = key === "home" ? ctx.settings.home()?.value : key === "trees" ? ctx.settings.trees()?.value : key === "shared" ? ctx.settings.shared()?.value : undefined;
424
+ // unset: back to the default under home (none for home itself)
425
+ const home = ctx.settings.home()?.value;
426
+ const target = next !== undefined ? ctx.resolvePath(next) : key === "shared" && home ? path.join(home, "shared") : key === "trees" ? home : undefined;
427
+ if (!now || (target && path.resolve(target) === path.resolve(now)))
428
+ return;
429
+ if (!planMove(now, target ?? now, []).content)
430
+ return;
431
+ throw new UsageError(`the research is in ${ctx.display(now)} — changing ${key} would leave it behind`, {
432
+ hint: "the user moves it: strom setup in their terminal (or the menu: Settings → the setup), which moves the trees and the shared folder along",
433
+ });
434
+ }
392
435
  function setUserSetting(ctx, key, value) {
393
436
  const s = ctx.settings;
437
+ guardResearchFolder(ctx, key, value === undefined ? undefined : String(value));
394
438
  // Loosening the agent's permissions is the user's decision alone.
395
439
  if (key === "agent.permissions" && raises(s.agentPermissions(), value))
396
440
  ctx.requireHuman(`Let the agent ${value === "full" ? "do everything but what the tree's permissions deny" : "work on its own, asking only about risky steps"}, without asking?`, `strom config set agent.permissions ${value}`, "agent.permissions", ui(ctx.uiLang(), value === "full" ? "ui.consent.level.full" : "ui.consent.level.auto"));
441
+ // Sessions steered from elsewhere (Remote Control): the user's decision alone.
442
+ if (key === "agent.remote" && value === "on" && !s.agentRemote())
443
+ ctx.requireHuman("Start the Claude Code sessions with Remote Control (followed and steered from claude.ai or your phone)?", "strom config set agent.remote on", "agent.remote", ui(ctx.uiLang(), "ui.consent.remote"));
397
444
  // Asking before a connector runs is the user's safeguard: only they take it away.
398
445
  if (key === "connectors.consent" && value !== "on" && s.connectorsConsent())
399
446
  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"));
447
+ // The gate decides what working alone spends: set and taken away by the user alone.
448
+ if (key === "run.gate" && value !== s.runGate())
449
+ 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()) }));
450
+ // a gate that is not there (or not a gate) is said now, not at the next run
451
+ const sh = s.shared()?.value;
452
+ if (key === "run.gate" && typeof value === "string" && sh) {
453
+ ensureGatesDir(sh);
454
+ loadGate(sh, value);
455
+ }
400
456
  writeStored(s.config, key, s.agent(ctx.hasTree() ? ctx.tree().config : undefined).value, value);
401
457
  s.save();
402
458
  if (key === "shared" && typeof value === "string")
@@ -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(),
@@ -17,6 +17,7 @@ import { AGENTS, findAgent, isAgent } from "../core/which.js";
17
17
  import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
18
18
  import { assertIntact } from "../core/integrity.js";
19
19
  import { treeBrowserConnectors } from "../core/connector.js";
20
+ import { browserNote } from "./connectors.js";
20
21
  import { appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
21
22
  import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
22
23
  import { openForUser } from "../core/open.js";
@@ -131,7 +132,7 @@ register({
131
132
  // its research when there is one, the day.
132
133
  name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
133
134
  shared,
134
- ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
135
+ ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
135
136
  };
136
137
  const args = conversationArgs(agent, base);
137
138
  if (opts.print)
@@ -147,6 +148,9 @@ register({
147
148
  const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
148
149
  const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
149
150
  ctx.io.stdout(ui(lang, "ui.chat.open", { agent: profile.name, exit: profile.exit }) + "\n");
151
+ const browserSays = browserNote(ctx, tree, agent);
152
+ if (browserSays)
153
+ ctx.io.stdout(browserSays + "\n");
150
154
  // Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
151
155
  if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
152
156
  ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
@@ -16,6 +16,8 @@ import { foldText } from "../core/text.js";
16
16
  import { now, typeOfId } from "../core/tree.js";
17
17
  import { resolveResearch } from "./research.js";
18
18
  import { makeNote } from "../core/actions.js";
19
+ import { phrase } from "../core/phrases.js";
20
+ import { readJsonLines } from "../core/json.js";
19
21
  import { lacksImages, rankTasks } from "../core/queue.js";
20
22
  import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
21
23
  function written(tree) {
@@ -82,6 +84,25 @@ export function rankedQueue(tree, filter = {}) {
82
84
  function taskLine(t) {
83
85
  return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
84
86
  }
87
+ /** Why a task was put aside: on the task, or — parked by an older strom — in the history. */
88
+ export function parkedWhy(tree, t) {
89
+ if (t.state !== "parked")
90
+ return undefined;
91
+ if (t.parkedReason)
92
+ return t.parkedReason;
93
+ const dir = path.join(tree.dataDir, "ops");
94
+ let last;
95
+ try {
96
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".jsonl")))
97
+ for (const o of readJsonLines(path.join(dir, f)))
98
+ if (o.op === "task.park" && o.targets.includes(t.id) && (!last || o.at > last.at))
99
+ last = o;
100
+ }
101
+ catch {
102
+ // no history to read
103
+ }
104
+ return last?.reason;
105
+ }
85
106
  function taskDetail(tree, t) {
86
107
  const where = t.where.map((w) => {
87
108
  const b = /^B\d{4,}$/.test(w) ? tree.get(w) : undefined;
@@ -89,7 +110,7 @@ function taskDetail(tree, t) {
89
110
  });
90
111
  const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
91
112
  const lessons = tree.list("lesson").filter((l) => l.target && t.where.includes(l.target));
92
- return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
113
+ return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, t.state === "parked" ? `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on` : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
93
114
  }
94
115
  function stateChange(tree, ref, state, fields, reason, verb) {
95
116
  const id = normId(ref, "task");
@@ -97,8 +118,12 @@ function stateChange(tree, ref, state, fields, reason, verb) {
97
118
  if (t.state === "done" && state !== "open")
98
119
  throw new UsageError(`${id} is already done`);
99
120
  const next = { ...t, state, ...fields };
100
- if (state !== "parked")
121
+ if (state !== "parked") {
101
122
  delete next.parkedUntil;
123
+ delete next.parkedReason;
124
+ }
125
+ else if (reason)
126
+ next.parkedReason = reason;
102
127
  if (state !== "waiting") {
103
128
  delete next.waitingOn;
104
129
  delete next.awaits;
@@ -401,10 +426,22 @@ register({
401
426
  group: "tasks",
402
427
  tree: true,
403
428
  writes: true,
429
+ description: "--answer: what the user found or decided for a task that waited for them (a link, a book's number) — kept on\nthe task with what it asked, so the agent reads both in its brief.",
404
430
  args: [{ name: "task", description: "task ID", required: true }],
405
- run(ctx, { args }) {
431
+ options: [{ name: "answer", type: "string", value: "<text>", description: "the user's answer to what the task waited for" }],
432
+ examples: ["strom task wake T0002", 'strom task wake T0003 --answer "https://archive.example.org/book/5359"'],
433
+ run(ctx, { args, opts }) {
406
434
  const tree = ctx.tree();
407
- const t = stateChange(tree, args[0], "open", {}, undefined, "wake");
435
+ const id = normId(args[0], "task");
436
+ const answer = typeof opts.answer === "string" && opts.answer.trim() ? opts.answer.trim() : undefined;
437
+ const t = tree.withTreeLock(() => {
438
+ const before = requireRecord(tree, id, "task");
439
+ // an answer is to what the task waits for: one that waits no more (done, dropped, taken up) is left as it is
440
+ if (answer && before.state !== "waiting")
441
+ throw new UsageError(`${id} does not wait for the user (it is ${before.state}) — the answer is not needed there`, { hint: `strom task show ${id}` });
442
+ const note = answer ? makeNote(tree, phrase(tree.lang, "task.answer", { asked: truncate(before.waitingOn || before.what, 120), answer })) : undefined;
443
+ return stateChange(tree, id, "open", note ? { notes: [...before.notes, note] } : {}, undefined, "wake");
444
+ });
408
445
  return { text: written(tree), data: { task: t } };
409
446
  },
410
447
  }, {