strom-research 1.7.0 → 1.9.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 +3 -0
  2. package/assets/lang/cs.json +117 -2
  3. package/assets/lang/de.json +117 -2
  4. package/assets/method/core.md +8 -0
  5. package/assets/method/narrate.md +5 -1
  6. package/assets/plugins/gates/claude-usage/gate.ts +2 -1
  7. package/dist/agents/files.js +6 -4
  8. package/dist/cli/main.js +21 -3
  9. package/dist/cli/menu-links.js +35 -1
  10. package/dist/cli/menu-research.js +41 -2
  11. package/dist/cli/menu-settings.js +17 -1
  12. package/dist/cli/menu.js +19 -8
  13. package/dist/cli/ui.js +57 -1
  14. package/dist/commands/guide.js +6 -1
  15. package/dist/commands/links.js +39 -3
  16. package/dist/commands/output.js +4 -2
  17. package/dist/commands/pack.js +21 -18
  18. package/dist/commands/research.js +42 -1
  19. package/dist/commands/session.js +210 -11
  20. package/dist/commands/setup.js +3 -0
  21. package/dist/commands/start.js +6 -3
  22. package/dist/commands/story.js +47 -6
  23. package/dist/commands/tasks.js +50 -12
  24. package/dist/commands/trees.js +82 -2
  25. package/dist/core/changelog.js +223 -0
  26. package/dist/core/clock.js +20 -0
  27. package/dist/core/config.js +2 -0
  28. package/dist/core/connector.js +25 -1
  29. package/dist/core/directions.js +41 -0
  30. package/dist/core/edge.js +296 -0
  31. package/dist/core/frontier.js +12 -4
  32. package/dist/core/kin.js +40 -1
  33. package/dist/core/links.js +17 -4
  34. package/dist/core/live.js +428 -52
  35. package/dist/core/pack.js +31 -0
  36. package/dist/core/phrases.js +61 -0
  37. package/dist/core/queue.js +15 -4
  38. package/dist/core/relocate.js +4 -4
  39. package/dist/core/session.js +8 -2
  40. package/dist/core/stromapp.js +22 -0
  41. package/dist/core/trash.js +72 -0
  42. package/dist/core/tree.js +1 -1
  43. package/dist/core/workers.js +28 -3
  44. package/dist/gedcom/export.js +105 -1
  45. package/dist/gedcom/validate.js +7 -1
  46. package/dist/runners/claude.js +2 -1
  47. package/dist/runners/jsonl.js +2 -2
  48. package/dist/runners/runner.js +16 -3
  49. package/dist/runners/script.js +2 -1
  50. package/package.json +1 -1
@@ -5,20 +5,24 @@
5
5
  // closes what the agent left open, exports the GEDCOM and commits. Between
6
6
  // sessions not a single token is spent.
7
7
  import fs from "node:fs";
8
+ import { spawnSync } from "node:child_process";
8
9
  import path from "node:path";
9
10
  import { register } from "../cli/registry.js";
10
11
  import { ui } from "../cli/ui.js";
11
12
  import { lines, table, truncate } from "../cli/format.js";
12
13
  import { NeedsConsentError, UsageError, StromError } from "../core/errors.js";
13
14
  import { closeSession, currentSession, openSessions, othersAtWork, sessionNote, startSession } from "../core/session.js";
15
+ import { reviveLive } from "../core/live.js";
14
16
  import { buildBrief } from "../brief/brief.js";
15
17
  import { DEFAULT_BUDGET, DEFAULT_RUN_MINUTES, Settings } from "../core/config.js";
16
18
  import { prependPath } from "../runners/runner.js";
17
19
  import { frontier } from "../core/frontier.js";
20
+ import { treeEdges } from "../core/edge.js";
21
+ import { label, parentsOf, resolvePerson } from "../core/people.js";
18
22
  import { storyProposals } from "../core/stories.js";
19
23
  import { OFF_MAP_HOW, offMapLine, placesOffMap } from "../core/places.js";
20
24
  import { create, csvOpt, requireRecord, update } from "../core/records.js";
21
- import { taskQueue, waitingLines } from "./tasks.js";
25
+ import { offTreeLine, taskQueue, waitingLines } from "./tasks.js";
22
26
  import { resolveResearch } from "./research.js";
23
27
  import { writeGedcoms } from "./output.js";
24
28
  import { syncAgentFiles } from "../agents/files.js";
@@ -28,15 +32,16 @@ import { PROFILES } from "../agents/profiles.js";
28
32
  import { AGENTS, detectAgent, isAgent, which, withoutAgentMarks } from "../core/which.js";
29
33
  import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
30
34
  import { keepAwake } from "../core/awake.js";
31
- import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
35
+ import { askFinish, deadlineOf, finishAsked, WRAP_UP_MS } from "../core/clock.js";
36
+ import { OWN_GROUP } from "../runners/runner.js";
32
37
  import { stromLauncher } from "../core/self.js";
33
38
  import { phrase } from "../core/phrases.js";
34
- import { enterWorker, runAlive, runsAtWork } from "../core/workers.js";
39
+ import { enterWorker, markPaused, runAlive, runsAtWork } from "../core/workers.js";
35
40
  import { assertIntact, snapshot, verifyFast } from "../core/integrity.js";
36
41
  import { guard } from "../core/guard.js";
37
42
  import { hasErrors } from "../core/check.js";
38
43
  import { Tree } from "../core/tree.js";
39
- import { treeBrowserConnectors } from "../core/connector.js";
44
+ import { agentBrowser, treeBrowserConnectors } from "../core/connector.js";
40
45
  import { browserNote } from "./connectors.js";
41
46
  import { reviewProposals } from "../core/review.js";
42
47
  function written(tree) {
@@ -70,6 +75,9 @@ export function researchProposals(tree, research) {
70
75
  }
71
76
  /** Create the tasks strom proposes for a research; returns their IDs. */
72
77
  export function applyFrontier(tree, research) {
78
+ // a direction paused or ended proposes nothing (resumed: what it would, at once)
79
+ if (research.state !== "active")
80
+ return [];
73
81
  return researchProposals(tree, research).map(({ proposal, kind }) => create(tree, "task", { ...proposal, state: "open" }, (id) => `+${id} task "${truncate(proposal.what, 60)}" (${kind})`).id);
74
82
  }
75
83
  /** Commit behind the same gate as every write. */
@@ -102,8 +110,11 @@ register({
102
110
  if (task && !["open", "doing", "parked"].includes(task.state))
103
111
  throw new UsageError(`${task.id} is ${task.state}`);
104
112
  const s = startSession(tree, { ...(task ? { task } : {}), ...(research ? { research: research.id } : {}) });
113
+ reviveLive(tree.root, ctx.env);
105
114
  const brief = buildBrief(tree, { ...(task ? { task } : {}), session: s, budget: ctx.settings.number("brief.budget", tree.config, DEFAULT_BUDGET), shared: ctx.settings.shared()?.value });
106
- return { text: lines(written(tree), "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections } };
115
+ // a task the queue holds back, started by its ID: the agent hears why before it begins
116
+ const off = task && args[0] ? offTreeLine(tree, task, true) : undefined;
117
+ return { text: lines(written(tree), off, "", brief.text), data: { session: s, brief: brief.text, sections: brief.sections, ...(off ? { offTree: true } : {}) } };
107
118
  },
108
119
  }, {
109
120
  path: ["session", "note"],
@@ -120,6 +131,30 @@ register({
120
131
  sessionNote(tree, s, args[0]);
121
132
  return { text: written(tree) };
122
133
  },
134
+ }, {
135
+ path: ["session", "finish"],
136
+ summary: "Ask a session at work to finish now: its agent writes down what it found and closes it",
137
+ group: "research",
138
+ tree: true,
139
+ description: "For the user: a session of strom run (or a conversation) is told, from the next strom command its agent runs, to\n" +
140
+ "start nothing new, record what it has and close the session (the task back to the queue when unfinished).\n" +
141
+ "Nothing is stopped by force; the run it belongs to stops after it (as at the first Ctrl-C in its terminal).",
142
+ args: [{ name: "session", description: "session ID (default: the one open session)" }],
143
+ examples: ["strom session finish", "strom session finish N0001"],
144
+ run(ctx, { args }) {
145
+ const tree = ctx.tree();
146
+ const lang = tree.lang;
147
+ const open = openSessions(tree);
148
+ const s = args[0] ? requireRecord(tree, args[0], "session") : open.length === 1 ? open[0] : undefined;
149
+ if (!s && !open.length)
150
+ throw new UsageError(ui(lang, "ui.session.finish.none"), { hint: "strom session list" });
151
+ if (!s)
152
+ throw new UsageError(ui(lang, "ui.session.finish.which", { sessions: open.map((x) => x.id).join(", ") }), { hint: `strom session finish ${open[0].id}` });
153
+ if (s.state !== "open")
154
+ throw new UsageError(ui(lang, "ui.session.finish.closed", { session: s.id }), { hint: "strom session list" });
155
+ askFinish(tree.root, s.id, ctx.env.STROM_WORKER ?? "user");
156
+ return { text: ui(lang, "ui.session.finish.done", { session: s.id, task: s.task ?? "–" }), data: { session: s.id, task: s.task ?? null, asked: true } };
157
+ },
123
158
  }, {
124
159
  path: ["session", "close"],
125
160
  summary: "Finish the session: summary, next step; exports the GEDCOM and fills the task queue",
@@ -261,7 +296,132 @@ register({
261
296
  data: { frontier: items.map((i) => ({ person: i.person.id, generation: i.generation, reason: i.reason, coveredBy: i.coveredBy, proposal: i.proposal, exhausted: i.exhausted })), stories },
262
297
  };
263
298
  },
299
+ }, {
300
+ path: ["edge"],
301
+ summary: "Where the tree ends: above whom it does not go on, why (what the records say so far) and what comes next — what the Strom app shows on those cards",
302
+ group: "tasks",
303
+ tree: true,
304
+ args: [{ name: "person", description: "whose edge in full: ID (P0001) or name (default: every edge the research reaches)", variadic: true }],
305
+ options: [{ name: "all", type: "boolean", description: "also the edges no direction reaches: family outside the directions, people off the tree" }],
306
+ examples: ["strom edge", "strom edge P0001", "strom edge --all --json"],
307
+ run(ctx, { args, opts }) {
308
+ const tree = ctx.tree();
309
+ const { edges, islands } = treeEdges(tree);
310
+ const who = (id) => {
311
+ const p = tree.get(id);
312
+ return p ? label(p) : id;
313
+ };
314
+ if (args.length) {
315
+ const people = args.map((a) => resolvePerson(tree, a));
316
+ const out = [];
317
+ for (const p of people) {
318
+ const e = edges.get(p.id);
319
+ const isle = islands.get(p.id);
320
+ if (out.length)
321
+ out.push("");
322
+ if (!e)
323
+ out.push(`${label(p)} — ${parentsOf(tree, p.id).length === 2 ? "the tree goes on above: a record proves the parents" : "nothing the research knows of the edge above yet (no direction reaches them, no work on it)"}`);
324
+ else
325
+ out.push(...edgeLines(tree, e, who));
326
+ if (isle)
327
+ out.push(islandLine(isle, who));
328
+ }
329
+ const data = people.map((p) => ({ person: p.id, edge: edges.get(p.id) ?? null, island: islands.get(p.id) ? { ...islands.get(p.id), people: islands.get(p.id).people.length } : null }));
330
+ return { text: lines(...out), data: { edges: data } };
331
+ }
332
+ const reached = new Set(["in", "limit", "paused", "done"]);
333
+ const shown = [...edges.values()].filter((e) => opts.all || reached.has(e.scope)).sort((a, b) => (a.generation ?? 99) - (b.generation ?? 99) || a.person.localeCompare(b.person));
334
+ const count = (k) => [...shown.reduce((m, e) => m.set(e[k], (m.get(e[k]) ?? 0) + 1), new Map())].map(([v, n]) => `${v} ${n}`).join(", ");
335
+ const hidden = edges.size - shown.length;
336
+ return {
337
+ text: shown.length
338
+ ? lines(table(shown.map((e) => [
339
+ e.person,
340
+ e.generation !== undefined ? `G${e.generation}` : "",
341
+ e.missing,
342
+ e.scope === "in" ? "" : e.scope,
343
+ e.end,
344
+ e.next,
345
+ truncate(edgeNext(e), 70),
346
+ ])), "", `ends: ${count("end")}`, `next: ${count("next")}`, hidden ? `${hidden} more no direction reaches (strom edge --all)` : undefined, "one in full: strom edge P…")
347
+ : "no edge the research reaches — every person in its directions has parents a record proves",
348
+ data: { edges: shown, islands: [...new Set(islands.values())].map((i) => ({ ...i, people: i.people.length, first: i.people[0] })) },
349
+ };
350
+ },
264
351
  });
352
+ /** What comes next at an edge, in a few words: the first task and where it stands, else what the user decides. */
353
+ function edgeNext(e) {
354
+ const t = e.tasks[0];
355
+ const hyp = e.hypotheses.find((h) => h.joins.length);
356
+ const join = hyp ? ` · ${hyp.id} ${hyp.island ? `joins ${hyp.joins.join(" ")} (${hyp.island.people} people off the tree)` : `would join through ${hyp.joins.join(" ")}`}` : "";
357
+ if (t)
358
+ return `${t.id}${t.position ? ` #${t.position}` : ""}${t.held ? ` (${t.held})` : ""}${e.tasks.length > 1 ? ` +${e.tasks.length - 1}` : ""}${join}`;
359
+ if (e.next === "decide")
360
+ return `tried ${e.tried.join(" ")} — ask the user${join}`;
361
+ return join.replace(/^ · /, "");
362
+ }
363
+ const MISSING_WORDS = {
364
+ parents: "both parents unknown",
365
+ father: "the father unknown",
366
+ mother: "the mother unknown",
367
+ proof: "no record proves the parents recorded",
368
+ };
369
+ const SCOPE_WORDS = {
370
+ in: "reached by",
371
+ limit: "at the limit of",
372
+ paused: "in a paused direction,",
373
+ done: "in an ended direction,",
374
+ living: "most likely alive — not researched",
375
+ outside: "family of the tree no direction goes through",
376
+ "off-tree": "of a family nothing links to the tree yet",
377
+ };
378
+ const END_WORDS = {
379
+ unnamed: "the baptism names no father (born out of wedlock)",
380
+ lost: "the records of those years are lost",
381
+ "before-records": "born before the known birth records of the place begin",
382
+ gap: "the known records have a gap in those years",
383
+ "not-found": "searched in vain everywhere strom knew to look",
384
+ offline: "the books left are only in the archive",
385
+ partly: "searched in part",
386
+ unsearched: "not searched yet",
387
+ "no-books": "no book of the birthplace known",
388
+ "no-place": "the birthplace unknown",
389
+ "no-clue": "nothing to go on: no year, no place of birth",
390
+ };
391
+ const NEXT_WORDS = {
392
+ working: "an agent works on it now",
393
+ waiting: "waits for the user",
394
+ queued: "in the queue",
395
+ held: "waits out of the queue",
396
+ proposed: "strom proposes it with the next session",
397
+ decide: "everything strom could propose was tried — the user decides",
398
+ none: "nothing planned",
399
+ };
400
+ /** One edge in full, for the agent. */
401
+ function edgeLines(tree, e, who) {
402
+ const y = (r) => (r.from === r.to ? `${r.from}` : `${r.from}–${r.to}`);
403
+ const est = e.estimate;
404
+ const basis = est?.basis ? ` (estimated from ${est.basis.kind === "MARR" ? "the marriage" : "the eldest child"} ${est.basis.year})` : "";
405
+ return [
406
+ `${who(e.person)} — the tree ends above: ${MISSING_WORDS[e.missing]}`,
407
+ ` ${SCOPE_WORDS[e.scope]}${e.research ? ` ${e.research}, generation ${e.generation}` : ""}`,
408
+ ` the records: ${END_WORDS[e.end]}${e.recordsFrom ? ` (from ${e.recordsFrom})` : ""}`,
409
+ e.window ? ` baptism sought ${y(e.window)}${est?.place ? ` in ${est.place}` : ""}${basis}` : est?.place ? ` born in ${est.place}, year unknown` : undefined,
410
+ e.books.length ? ` books: ${e.books.map((b) => `${b.id}${b.years ? ` (${b.years})` : ""}${b.access.startsWith("online") ? "" : ` ${b.access}`}`).join(", ")}` : undefined,
411
+ e.covered.length ? ` searched in vain: ${e.covered.map(y).join(", ")}` : undefined,
412
+ e.noRecords.length ? ` no records known: ${e.noRecords.map(y).join(", ")}` : undefined,
413
+ ` next: ${NEXT_WORDS[e.next]}`,
414
+ ...e.tasks.map((t) => ` ${t.id} ${t.level} ${t.state}${t.position ? ` #${t.position}` : ""}${t.held ? ` (waits: ${t.held}${t.until ? ` until ${t.until}` : ""})` : ""}${t.on ? ` — ${truncate(t.on, 60)}` : ""}: ${truncate(t.what, 90)}`),
415
+ e.tried.length ? ` tried: ${e.tried.join(" ")}` : undefined,
416
+ ...e.hypotheses.map((h) => ` ${h.id} ${truncate(h.question, 90)}${!h.joins.length ? "" : h.island ? ` — would join ${h.joins.map(who).join(", ")} of a family off the tree (${h.island.people} people, ${h.island.held} tasks wait for it)` : ` — would join them to the tree through ${h.joins.map(who).join(", ")}`}${h.tests.length ? `; tested by ${h.tests.join(" ")}` : "; no task tests it"}`),
417
+ e.conflicts.length ? ` open conflicts: ${e.conflicts.join(" ")}` : undefined,
418
+ e.searches || e.sessions ? ` work so far: ${e.searches} search${e.searches === 1 ? "" : "es"}, ${e.sessions} session${e.sessions === 1 ? "" : "s"}${e.cost !== undefined ? `, $${e.cost.toFixed(2)}${e.costPartial ? "+" : ""}` : ""}${e.last ? `; last ${e.last}` : ""}` : undefined,
419
+ ].filter((x) => x !== undefined);
420
+ }
421
+ function islandLine(i, who) {
422
+ const joins = i.hypotheses.map((h) => `${h.id} (to ${h.joins.map(who).join(", ")})`);
423
+ return ` of a family nothing links to the tree: ${i.people.length} people, ${i.held} tasks wait for it${joins.length ? `; would join it: ${joins.join(", ")}` : "; no hypothesis would join it yet"}`;
424
+ }
265
425
  // ── agents ─────────────────────────────────────────────────────────────────
266
426
  register({
267
427
  path: ["agents", "sync"],
@@ -402,6 +562,7 @@ register({
402
562
  { name: "loop", type: "boolean", description: "session after session for as long as there is work (and the gate lets it)" },
403
563
  { 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
564
  { name: "no-gate", type: "boolean", description: "ask no gate this time (only you: an agent cannot)" },
565
+ { name: "follow", type: "boolean", description: "open the Strom app to follow the work live when the first session starts (after the gate)" },
405
566
  { name: "minutes", type: "string", value: "<n>", description: `time limit of one session (default: run.minutes, ${DEFAULT_RUN_MINUTES})` },
406
567
  { name: "budget", type: "string", value: "<tokens>", description: `brief size (default: brief.budget, ${DEFAULT_BUDGET})` },
407
568
  { name: "model", type: "string", value: "<model>", description: "model of the main agent (default: model.lead)" },
@@ -439,15 +600,27 @@ register({
439
600
  const report = [];
440
601
  // What the gate answered, for the record of the run.
441
602
  const gates = [];
603
+ // The Strom app opened to follow the run (--follow): once, when its first session starts.
604
+ let followed = false;
442
605
  // Why the run stopped: a code for the exit status and for data, words for the user (their language).
443
606
  const lang = Tree.open(root, runEnv).lang;
444
607
  let stopCode = "done";
445
608
  let stopValues = {};
446
- // Ctrl-C, a closed terminal, a shutdown: stop the agent, close its session, give the task back.
609
+ // The first Ctrl-C while a session works: it is asked to finish (the agent writes down what it found and closes
610
+ // it), then the run stops — no next session. Ctrl-C again, a closed terminal, a shutdown: stop the agent now,
611
+ // close its session, give the task back. (Windows: the console stops the agent itself — now.)
447
612
  const stop = new AbortController();
613
+ let atWorkOn;
614
+ let finishing = false;
448
615
  const onSignal = (sig) => {
449
616
  if (stop.signal.aborted)
450
- process.exit(130); // a second Ctrl-C: now
617
+ process.exit(130); // a third Ctrl-C: at once
618
+ if (sig === "SIGINT" && atWorkOn && !finishing && OWN_GROUP && !opts.interactive) {
619
+ finishing = true;
620
+ askFinish(root, atWorkOn, "user");
621
+ out(`\n${ui(lang, "ui.run.finishing", { session: atWorkOn })}`);
622
+ return;
623
+ }
451
624
  out(`\n${ui(lang, "ui.run.stopping")} (${sig})`);
452
625
  stop.abort();
453
626
  };
@@ -478,6 +651,10 @@ register({
478
651
  const permissions = ctx.settings.agentPermissions();
479
652
  for (const c of browser)
480
653
  out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
654
+ // agent.browser always (the user's choice): the browser in every session, for the research's archives
655
+ const web = runnerId === "claude" ? agentBrowser(Tree.open(root, runEnv), ctx.settings.shared()?.value) : undefined;
656
+ if (web?.always)
657
+ out(ui(lang, "ui.run.browser.always", { hosts: web.hosts.length ? web.hosts.join(", ") : "–" }));
481
658
  if (browser.length && /haiku/i.test(models.lead ?? ""))
482
659
  out(ui(lang, "ui.run.browser.model", { model: models.lead }));
483
660
  // Archives through the browser with an agent that has no browser tools, or without the extension: said before it starts.
@@ -493,7 +670,7 @@ register({
493
670
  if (gate)
494
671
  out(ui(lang, gateHolds ? "ui.run.gate" : "ui.run.gate.once", { name: gate.manifest.title ?? gate.name }));
495
672
  for (let i = 0; i < max; i++) {
496
- if (stop.signal.aborted) {
673
+ if (stop.signal.aborted || finishing) {
497
674
  stopCode = "user";
498
675
  break;
499
676
  }
@@ -558,7 +735,14 @@ register({
558
735
  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
736
  // nothing to do meanwhile: the computer may sleep
560
737
  stopAwake();
561
- await pause(at - Date.now(), stop.signal);
738
+ // the Strom app shows the run paused until then, not at work
739
+ markPaused(root, worker, { until: new Date(at).toISOString(), ...(said.reason ? { reason: said.reason } : {}) });
740
+ try {
741
+ await pause(at - Date.now(), stop.signal);
742
+ }
743
+ finally {
744
+ markPaused(root, worker, undefined);
745
+ }
562
746
  stopAwake = keepAwake(ctx.env);
563
747
  i--; // this was no session: ask again, with the queue as it is then
564
748
  continue;
@@ -566,6 +750,17 @@ register({
566
750
  }
567
751
  const session = startSession(tree, { task, ...(research ? { research: research.id } : {}), runner: runnerId, ...(models.lead ? { model: models.lead } : {}) });
568
752
  commitNow(tree, `${session.id} session started on ${task.id}`);
753
+ // the Strom app followed the research and its bridge ended without a word: on its address again
754
+ reviveLive(root, ctx.env);
755
+ // The Strom app follows it from the first session on — not while the run still waits for its gate.
756
+ if (opts.follow && !followed) {
757
+ followed = true;
758
+ const { command, args: self } = stromLauncher();
759
+ const app = spawnSync(command, [...self, "app", "--live"], { cwd: root, env: ctx.env, encoding: "utf8", windowsHide: true, timeout: 60_000 });
760
+ const said = `${app.stdout ?? ""}${app.stderr ?? ""}`.trim();
761
+ if (said)
762
+ out(said);
763
+ }
569
764
  // 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
765
  const deadline = Date.now() + minutes * 60_000;
571
766
  const brief = buildBrief(tree, { task, session, budget, shared: ctx.settings.shared()?.value, deadline });
@@ -584,6 +779,7 @@ register({
584
779
  STROM_MINUTES: String(minutes),
585
780
  ...(opts.interactive ? {} : { STROM_NONINTERACTIVE: "1" }),
586
781
  };
782
+ atWorkOn = session.id;
587
783
  const result = await runner.run({
588
784
  cwd: root,
589
785
  prompt,
@@ -605,16 +801,19 @@ register({
605
801
  ...(opts.interactive ? { interactive: true } : {}),
606
802
  // the level for every agent (each maps it to its own switches); the browser and Remote Control are Claude Code's
607
803
  permissions,
608
- ...(runnerId === "claude" ? { chrome: browser.length > 0, remote: ctx.settings.agentRemote() } : {}),
804
+ ...(runnerId === "claude" ? { chrome: Boolean(web?.on), remote: ctx.settings.agentRemote() } : {}),
609
805
  ...(extra?.length ? { extraArgs: extra } : {}),
610
806
  onProgress: (l) => out(` · ${l}`),
611
807
  signal: stop.signal,
612
808
  });
809
+ atWorkOn = undefined;
613
810
  // Close what the agent left open, record the metrics, export, commit.
614
811
  const after = Tree.open(root, runEnv);
615
812
  let s = after.get(session.id);
616
813
  // strom worked for the agent when it closed the session itself or recorded something
617
814
  const ranStrom = s.state !== "open" || sessionWrites(after, s.id) > 0;
815
+ // the user asked it to finish (strom session finish, the Strom app's "finish and stop"): no next session
816
+ const askedToFinish = finishAsked(root, s.id) || s.endedBy === "user";
618
817
  if (s.state === "open")
619
818
  s = closeSession(after, s, {
620
819
  summary: result.outcome === "stopped" ? phrase(after.lang, "session.user") : phrase(after.lang, "session.agent", { outcome: result.outcome }),
@@ -653,7 +852,7 @@ register({
653
852
  stopCode = "problems";
654
853
  break;
655
854
  }
656
- if (stop.signal.aborted) {
855
+ if (stop.signal.aborted || finishing || askedToFinish) {
657
856
  stopCode = "user";
658
857
  break;
659
858
  }
@@ -453,6 +453,9 @@ function setUserSetting(ctx, key, value) {
453
453
  // Loosening the agent's permissions is the user's decision alone.
454
454
  if (key === "agent.permissions" && raises(s.agentPermissions(), value))
455
455
  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"));
456
+ // The browser in every session: the user's decision alone.
457
+ if (key === "agent.browser" && value === "always" && s.resolve("agent.browser", ctx.hasTree() ? ctx.tree().config : undefined)?.value !== "always")
458
+ ctx.requireHuman("Give the agent browser tools (Claude in Chrome) in every research session?", "strom config set agent.browser always", "agent.browser", ui(ctx.uiLang(), "ui.consent.browser"));
456
459
  // Sessions steered from elsewhere (Remote Control): the user's decision alone.
457
460
  if (key === "agent.remote" && value === "on" && !s.agentRemote())
458
461
  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"));
@@ -16,7 +16,7 @@ import { syncAgentFiles } from "../agents/files.js";
16
16
  import { AGENTS, findAgent, isAgent, withoutAgentMarks } from "../core/which.js";
17
17
  import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
18
18
  import { assertIntact } from "../core/integrity.js";
19
- import { treeBrowserConnectors } from "../core/connector.js";
19
+ import { agentBrowser } from "../core/connector.js";
20
20
  import { browserNote } from "./connectors.js";
21
21
  import { offerLinks } from "../cli/wizard.js";
22
22
  import { appOpensLinks, appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
@@ -134,7 +134,7 @@ register({
134
134
  // its research when there is one, the day.
135
135
  name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
136
136
  shared,
137
- ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
137
+ ...(agent === "claude" ? { chrome: agentBrowser(tree, shared).on, remote: ctx.settings.agentRemote() } : {}),
138
138
  };
139
139
  const args = conversationArgs(agent, base);
140
140
  if (opts.print)
@@ -263,7 +263,10 @@ register({
263
263
  tree: true,
264
264
  description: "A small web server on this computer only (127.0.0.1, a secret address), that only reads: the Strom app, opened with\n" +
265
265
  "strom app --live, takes the tree from it and hears what changes — who is at work on what, what was recorded, what\n" +
266
- "waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop.",
266
+ "waits for the user. It ends by itself when nobody asks it anything for two hours, or with strom live stop.\n" +
267
+ "One error does not end it (a read that fails is tried again); what it did and what went wrong is in .strom/live.log.\n" +
268
+ "Started again it takes the address it had (its port while free), so the app following it goes on by itself;\n" +
269
+ "one that ended without a word is started again when a session starts.",
267
270
  examples: ["strom live", "strom live start", "strom live stop"],
268
271
  run(ctx) {
269
272
  const tree = ctx.tree();
@@ -8,6 +8,42 @@ import { approveStory, setStory, findEventOwner } from "../core/actions.js";
8
8
  import { UsageError } from "../core/errors.js";
9
9
  import { csvOpt, normId, textOpt } from "../core/records.js";
10
10
  import { resolvePerson } from "../core/people.js";
11
+ import { foldText } from "../core/text.js";
12
+ /**
13
+ * The one heading a story has is its title (--title). A first line "# …" is taken as the title when none is given,
14
+ * and dropped when it only repeats it; any other "# …" line stays (the Strom app shows it as a subheading).
15
+ */
16
+ function titleOf(text, title) {
17
+ const [first = "", ...rest] = text.replace(/\r\n?/g, "\n").trimStart().split("\n");
18
+ const heading = /^#[ \t]+(.+?)[ \t#]*$/.exec(first)?.[1]?.trim();
19
+ if (!heading)
20
+ return { text, ...(title ? { title } : {}) };
21
+ if (!title?.trim())
22
+ return { text: rest.join("\n"), title: heading };
23
+ return foldText(heading) === foldText(title) ? { text: rest.join("\n"), title } : { text, title };
24
+ }
25
+ /**
26
+ * What the Strom app shows as plain text (its set: paragraphs, "## " subheadings, "- " bullets, **bold**,
27
+ * *italic*): said, never refused. Numbered lists count only as two such lines in a row — a date may start a line.
28
+ */
29
+ function outsideSet(text) {
30
+ const found = [];
31
+ const lns = text.split("\n");
32
+ if (/\[[^\]\n]+\]\([^)\n]+\)/.test(text))
33
+ found.push("links");
34
+ if (lns.filter((l) => /^\s*\|.*\|\s*$/.test(l)).length >= 2)
35
+ found.push("tables");
36
+ if (lns.some((l) => /^\s*>/.test(l)))
37
+ found.push("quotes (> )");
38
+ if (/`[^`\n]+`/.test(text))
39
+ found.push("code");
40
+ if (lns.some((l, i) => /^\s*\d+[.)]\s/.test(l) && /^\s*\d+[.)]\s/.test(lns[i + 1] ?? "")))
41
+ found.push("numbered lists");
42
+ // "* 1831" is the sign of a birth, no bullet
43
+ if (lns.some((l) => /^\s*[*+]\s+(?!\d)/.test(l)))
44
+ found.push('bullets other than "- "');
45
+ return found;
46
+ }
11
47
  function owner(tree, ref) {
12
48
  return /^F\d+$/i.test(ref.trim()) ? normId(ref, "family") : resolvePerson(tree, ref).id;
13
49
  }
@@ -17,8 +53,10 @@ register({
17
53
  group: "people",
18
54
  tree: true,
19
55
  writes: true,
20
- description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line;\n" +
21
- "**bold** is kept. Writing it again replaces it (the history keeps the old one).",
56
+ description: "Every statement rests on a recorded fact: list them with --fact. Paragraphs are separated by a blank line.\n" +
57
+ "Kept: \"## subheading\" lines, \"- \" bullet lines, **bold**, *italic*; anything else shows as plain text in the\n" +
58
+ "Strom app. The title goes in --title (a first line \"# …\" is taken as it). Writing it again replaces it (the\n" +
59
+ "history keeps the old one).",
22
60
  args: [{ name: "who", description: "person (ID or name) or family (F…)", required: true }],
23
61
  options: [
24
62
  { name: "text", type: "string", value: "<text|@file>", description: "the story, in the research language" },
@@ -30,18 +68,21 @@ register({
30
68
  examples: ['strom story set P0001 --text @notes/story-P0001.md --title "The miller of Týnec" --fact E0001 --fact E0002 --note "The house is inferred from the census."'],
31
69
  run(ctx, { args, opts }) {
32
70
  const tree = ctx.tree();
33
- const text = textOpt(opts.text, (p) => fs.readFileSync(ctx.resolvePath(p), "utf8"));
34
- if (!text?.trim())
71
+ const given = textOpt(opts.text, (p) => fs.readFileSync(ctx.resolvePath(p), "utf8"));
72
+ if (!given?.trim())
35
73
  throw new UsageError("--text is required", { hint: "--text @notes/story.md (a file) or the text itself" });
74
+ const { text, title } = titleOf(given, opts.title);
36
75
  const rec = setStory(tree, owner(tree, args[0]), {
37
76
  text,
38
- title: opts.title,
77
+ title,
39
78
  facts: csvOpt(opts.fact).map((f) => normId(f)),
40
79
  note: opts.note,
41
80
  final: Boolean(opts.final),
42
81
  });
43
82
  const missing = rec.story.facts.length === 0 ? "note: no --fact given — say which facts the story rests on" : undefined;
44
- return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing), data: { story: rec.story } };
83
+ const outside = outsideSet(rec.story.text);
84
+ const plain = outside.length ? `note: the Strom app shows ${outside.join(", ")} as plain text — keep to paragraphs, "## " subheadings, "- " bullets, **bold**, *italic*` : undefined;
85
+ return { text: lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, missing, plain), data: { story: rec.story } };
45
86
  },
46
87
  }, {
47
88
  path: ["story", "approve"],