strom-research 1.2.0 → 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.
@@ -18,6 +18,7 @@ import { openForUser } from "../core/open.js";
18
18
  import { createShortcut } from "../core/shortcut.js";
19
19
  import { desktopDir } from "../core/paths.js";
20
20
  import { ensureShared } from "../commands/setup.js";
21
+ import { moveHome, planMove, repointSettings, sameFolder } from "../core/relocate.js";
21
22
  /** The shortcut's name, in the user's language. */
22
23
  export function shortcutName(lang) {
23
24
  return ui(lang, "ui.dialog.title");
@@ -46,9 +47,18 @@ export async function setupWizard(ctx) {
46
47
  // Run again: each choice can be left as it is (0), the current one suggested.
47
48
  const keep = first ? {} : { back: ui(lang, "ui.keep") };
48
49
  // 2. Where the research lives.
49
- const home = ctx.resolvePath(await ctx.ask(ui(lang, "ui.setup.home"), ctx.display(s.home()?.value ?? s.suggestedHome())));
50
+ const was = s.home()?.value;
51
+ const typed = (await ctx.ask(ui(lang, first ? "ui.setup.home" : "ui.setup.home.again"), ctx.display(was ?? s.suggestedHome()))).trim();
52
+ // Run again: 0 (or nothing) keeps the folder, like every other answer here.
53
+ let home = was && (typed === "0" || !typed) ? was : ctx.resolvePath(typed || ctx.display(s.suggestedHome()));
54
+ // Another folder while the research is in the old one: it moves along (trees, the shared folder) — or stays where it is.
55
+ if (was && !sameFolder(home, was))
56
+ home = await moveResearch(ctx, lang, was, home);
50
57
  cfg.lang = lang;
51
- cfg.home = home;
58
+ if (was && !sameFolder(home, was))
59
+ repointSettings(s, ctx.env, was, home);
60
+ else if (!was)
61
+ cfg.home = home;
52
62
  s.save();
53
63
  fs.mkdirSync(home, { recursive: true });
54
64
  fs.mkdirSync(s.trees().value, { recursive: true });
@@ -167,3 +177,39 @@ export async function askStromApp(ctx, lang, back = ui(lang, "ui.browse.back"))
167
177
  }
168
178
  return answer;
169
179
  }
180
+ /**
181
+ * The research to another folder: what moves is said (the trees, the shared folder with the plugins and images), and it
182
+ * moves on the person's yes. Where it cannot (somebody at work, a folder with something in it, into itself) it stays.
183
+ * Returns the folder the research is in afterwards.
184
+ */
185
+ async function moveResearch(ctx, lang, from, to) {
186
+ const out = (line) => ctx.io.stdout(line + "\n");
187
+ // Set from outside (STROM_HOME): the folder is not the wizard's to change.
188
+ if (ctx.settings.home()?.source !== "config") {
189
+ out(ui(lang, "ui.home.env", { from: ctx.display(from) }));
190
+ return from;
191
+ }
192
+ const plan = planMove(from, to, ctx.knownTrees().map((k) => k.root));
193
+ if (!plan.content)
194
+ return to;
195
+ const where = { from: ctx.display(from), to: ctx.display(plan.to) };
196
+ if (plan.problem) {
197
+ out(ui(lang, `ui.home.${plan.problem}`, { ...where, trees: plan.busy.join(", ") }));
198
+ out(ui(lang, "ui.home.stays", where));
199
+ return from;
200
+ }
201
+ out(ui(lang, "ui.home.what", { ...where, trees: plan.trees.length ? plan.trees.join(", ") : "–" }));
202
+ if (!(await ctx.confirm(ui(lang, "ui.home.sure"), true))) {
203
+ out(ui(lang, "ui.home.stays", where));
204
+ return from;
205
+ }
206
+ try {
207
+ const how = moveHome(plan);
208
+ out(ui(lang, how === "moved" ? "ui.home.moved" : "ui.home.copied", where));
209
+ return plan.to;
210
+ }
211
+ catch (err) {
212
+ out(ui(lang, "ui.home.failed", { ...where, reason: err.message }));
213
+ return from;
214
+ }
215
+ }
@@ -115,7 +115,7 @@ register({
115
115
  (s.sessions.costUsd ? ui(lang, "ui.stats.cost", { cost: humanCost(s.sessions.costUsd, lang) }) : "") +
116
116
  (s.sessions.costUsd && s.sessions.costPartial ? ui(lang, "ui.stats.cost.partial", { n: s.sessions.costPartial }) : ""));
117
117
  if (s.stories.written)
118
- out.push(ui(lang, "ui.stats.stories", { n: s.stories.written, final: s.stories.final }));
118
+ out.push(ui(lang, "ui.stats.stories", { n: s.stories.written }));
119
119
  return { text: lines(...out), data: s };
120
120
  },
121
121
  }, {
@@ -164,7 +164,7 @@ register({
164
164
  }
165
165
  if (c.story) {
166
166
  const text = c.story.text.replace(/\*\*/g, "").trim();
167
- out.push("", ui(lang, "ui.card.story", { status: ui(lang, c.story.status === "final" ? "ui.card.story.final" : "ui.card.story.draft") }));
167
+ out.push("", ui(lang, "ui.card.story"));
168
168
  // markdown headings as plain lines
169
169
  out.push(text.split("\n").map((l) => (l.trim() ? ` ${l.trim().replace(/^#+\s*/, "")}` : "")).join("\n"));
170
170
  }
@@ -26,7 +26,8 @@ import { imageSizeOfFile } from "../image/index.js";
26
26
  import { Tree } from "../core/tree.js";
27
27
  import { syncAgentFiles } from "../agents/files.js";
28
28
  import { clearBlock, CookieJar, DEFAULT_PACE, hostAllowed, hostPace, hostState, knownHosts, MIN_INTERVAL_MS, NetError, paceText, politeRequest, refusedBy, reserveSlots, setOwnPace } from "../core/net.js";
29
- import { botCheck, fileBase, findDownload, loadPage, loadPlans, pageKey, pageScript, parseResult, PLAN_TTL_MS, planScript, readSavedPage, removePlan, savePage, savePlan, SCRIPT_MS, } from "../core/browser.js";
29
+ import { botCheck, fileBase, findDownload, loadPage, loadPlans, pageKey, pageScript, parseResult, PLAN_TTL_MS, planScript, readSavedPage, removePlan, savePage, savePlan, SCRIPT_MS, claudeInChrome, CLAUDE_IN_CHROME_URL, } from "../core/browser.js";
30
+ import { PROFILES } from "../agents/profiles.js";
30
31
  import { bareHost, changedSinceConsent, codeWarning, consentRequired, connectorHash, connectorsDir, directNetwork, ensurePluginsDir, findConnector, hostWarning, imageProblem, INTERFACE, listConnectors, loadConsents, MANIFEST, missingConsents, NAME_RE, readManifest, routeOf, treeBrowserConnectors, routesOf, ROUTES, runConnector, saveConsents, scanConnectors, suggestName, } from "../core/connector.js";
31
32
  import { registerImages } from "./media.js";
32
33
  function shared(ctx) {
@@ -304,7 +305,7 @@ register({
304
305
  const hosts = c.manifest.hosts.map(bareHost);
305
306
  return {
306
307
  text: via === "browser"
307
- ? lines(`${c.name}: images through your browser from now on (${hosts.join(", ")})`, ` · the agent gets browser tools (Claude in Chrome) for these sites only, in the family trees that work with this`, ` archive (images fetched through it, or an archive, book or record of theirs on its site), from its next session${synced.length ? ` (permissions of ${synced.join(", ")} updated)` : ""}. Chrome with the`, " Claude extension must be running then, signed in to the account Claude Code uses; browser tools need a model", " that can review its actions (Sonnet or Opus, not Haiku)", ` · strom plans and paces every request, and takes the files over from ${ctx.display(s.downloads())}`, " (another folder: strom config set browser.downloads <folder>)", ` · the first time, Chrome asks whether ${hosts[0]} may download several files: allow it once`, ` · back: strom connector use ${c.name} --via direct`)
308
+ ? lines(`${c.name}: images through your browser from now on (${hosts.join(", ")})`, ` · the agent gets browser tools (Claude in Chrome) for these sites only, in the family trees that work with this`, ` archive (images fetched through it, or an archive, book or record of theirs on its site), from its next session${synced.length ? ` (permissions of ${synced.join(", ")} updated)` : ""}. Chrome with the`, " Claude extension must be running then, signed in to the account Claude Code uses; browser tools need a model", " that can review its actions (Sonnet or Opus, not Haiku)", ` · strom plans and paces every request, and takes the files over from ${ctx.display(s.downloads())}`, " (another folder: strom config set browser.downloads <folder>)", ` · the first time, Chrome asks whether ${hosts[0]} may download several files: allow it once`, ` · back: strom connector use ${c.name} --via direct`, ...browserWarnings(ctx))
308
309
  : lines(`${c.name}: images directly through strom from now on, paced (${hosts.join(", ")})`, routesOf(c).includes("browser") ? ` · through your browser again: strom connector use ${c.name} --via browser` : undefined),
309
310
  data: { connector: c.name, via, hosts, synced },
310
311
  };
@@ -1440,3 +1441,25 @@ function textFiles(dir) {
1440
1441
  walk(dir);
1441
1442
  return out;
1442
1443
  }
1444
+ /**
1445
+ * What the person is told of the archives this tree reaches through their browser, for the agent that works: only
1446
+ * Claude Code has browser tools (Claude in Chrome), and the extension must be in a browser here. Nothing to say: undefined.
1447
+ */
1448
+ export function browserNote(ctx, tree, agent) {
1449
+ const via = treeBrowserConnectors(tree, ctx.settings.shared()?.value);
1450
+ if (!via.length)
1451
+ return undefined;
1452
+ const lang = ctx.uiLang();
1453
+ const names = via.map((c) => c.manifest.title ?? c.name).join(", ");
1454
+ if (agent !== "claude")
1455
+ return ui(lang, "ui.browser.agent", { names, agent: PROFILES[agent]?.name ?? agent });
1456
+ const found = claudeInChrome(ctx.env).extension;
1457
+ return found.length ? undefined : ui(lang, "ui.browser.noext", { names, url: CLAUDE_IN_CHROME_URL });
1458
+ }
1459
+ /** Through the browser: what stands in the way here (English, for the agent — which tells the user). */
1460
+ function browserWarnings(ctx) {
1461
+ const agent = ctx.settings.agent(ctx.hasTree() ? ctx.tree().config : undefined).value;
1462
+ if (agent !== "claude")
1463
+ return [` ⚠ only Claude Code has browser tools (Claude in Chrome); this research's agent is ${PROFILES[agent]?.name ?? agent} — tell the user: they save these images by hand, or choose Claude Code (strom agents use claude)`];
1464
+ return claudeInChrome(ctx.env).extension.length ? [] : [` ⚠ the Claude in Chrome extension is in no browser here — tell the user to install it and sign in: ${CLAUDE_IN_CHROME_URL}`];
1465
+ }
@@ -0,0 +1,141 @@
1
+ // Hooks: the user's programs told of what is saved into a research (core/hooks.ts).
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { register } from "../cli/registry.js";
5
+ import { lines, table } from "../cli/format.js";
6
+ import { ui } from "../cli/ui.js";
7
+ import { StromError, UsageError } from "../core/errors.js";
8
+ import { readJsonLines } from "../core/json.js";
9
+ import { prependPath } from "../runners/runner.js";
10
+ import { shimDir } from "./session.js";
11
+ import { ensureHooksDir, forHook, HOOK_INTERFACE, hooksDir, listHooks, loadHook, runHookNow } from "../core/hooks.js";
12
+ function shared(ctx) {
13
+ const s = ctx.settings.shared()?.value;
14
+ if (!s)
15
+ throw new StromError("strom is not set up yet", { hint: "strom setup" });
16
+ ensureHooksDir(s);
17
+ return s;
18
+ }
19
+ /** Turning a hook on or off is the user's decision: an agent asks them (a window of the system). */
20
+ function setHook(ctx, name, on) {
21
+ const s = shared(ctx);
22
+ const now = ctx.settings.config.hooks ?? [];
23
+ if (now.includes(name) === on)
24
+ return { text: `${name}: ${on ? "on" : "off"} already`, data: { hook: name, on, hooks: now } };
25
+ // off: also a hook whose folder is gone or broken — it goes from the list all the same
26
+ let hook;
27
+ try {
28
+ hook = loadHook(s, name);
29
+ }
30
+ catch (err) {
31
+ if (on)
32
+ throw err;
33
+ }
34
+ const title = hook?.manifest.title ?? name;
35
+ ctx.requireHuman(on ? `Let the hook "${name}" be told of everything saved into your researches?` : `Stop telling the hook "${name}"?`, `strom hook ${on ? "on" : "off"} ${name}`, "hooks", ui(ctx.uiLang(), on ? "ui.consent.hook.on" : "ui.consent.hook.off", { name: title }));
36
+ ctx.settings.reload();
37
+ const next = on ? [...(ctx.settings.config.hooks ?? []).filter((h) => h !== name), name] : (ctx.settings.config.hooks ?? []).filter((h) => h !== name);
38
+ ctx.settings.config.hooks = next.length ? next : undefined;
39
+ ctx.settings.save();
40
+ return {
41
+ text: on && hook ? `${name}: on — told of what is saved (${(hook.manifest.events ?? ["*"]).join(", ")}); its output: ${ctx.display(path.join(hook.dir, "hook.log"))}` : `${name}: off`,
42
+ data: { hook: name, on, hooks: next },
43
+ };
44
+ }
45
+ /** The last operations saved into this research, newest last. */
46
+ function lastOps(root, n) {
47
+ const dir = path.join(root, "data", "ops");
48
+ let files = [];
49
+ try {
50
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
51
+ }
52
+ catch {
53
+ return [];
54
+ }
55
+ return files
56
+ .flatMap((f) => readJsonLines(path.join(dir, f)))
57
+ .sort((a, b) => a.at.localeCompare(b.at))
58
+ .slice(-n)
59
+ .map((o) => ({ op: o.op, targets: o.targets, summary: o.summary, at: o.at, by: o.by, ...(o.reason ? { reason: o.reason } : {}) }));
60
+ }
61
+ register({
62
+ path: ["hook", "list"],
63
+ summary: "Hooks: your programs told of what is saved into a research (a message to your phone, a log…) — which there are, which are on",
64
+ group: "setup",
65
+ run(ctx) {
66
+ const s = shared(ctx);
67
+ const on = ctx.settings.config.hooks ?? [];
68
+ const hooks = listHooks(s);
69
+ return {
70
+ text: lines(hooks.length
71
+ ? table(hooks.map((h) => [on.includes(h.name) ? `${h.name} ◀ on` : h.name, h.manifest.title ?? "", (h.manifest.events ?? ["*"]).join(" "), h.manifest.command.join(" ")]))
72
+ : "no hooks", on.length ? `on: ${on.join(", ")} (strom hook off <name>)` : "none is on: strom hook on <name> (only you)", `folder: ${ctx.display(hooksDir(s))} — copy a hook's folder in to install it (the interface: README.md there)`),
73
+ data: {
74
+ folder: hooksDir(s),
75
+ on,
76
+ hooks: hooks.map((h) => ({ name: h.name, title: h.manifest.title, events: h.manifest.events ?? ["*"], command: h.manifest.command, dir: h.dir, on: on.includes(h.name) })),
77
+ },
78
+ };
79
+ },
80
+ }, {
81
+ path: ["hook", "on"],
82
+ summary: "Turn a hook on: from now on it is told of what is saved into your researches (only you)",
83
+ group: "setup",
84
+ args: [{ name: "name", description: "the hook's folder name", required: true }],
85
+ examples: ["strom hook on telegram"],
86
+ run: (ctx, { args }) => setHook(ctx, args[0], true),
87
+ }, {
88
+ path: ["hook", "off"],
89
+ summary: "Turn a hook off (only you)",
90
+ group: "setup",
91
+ args: [{ name: "name", description: "the hook's folder name", required: true }],
92
+ run: (ctx, { args }) => setHook(ctx, args[0], false),
93
+ }, {
94
+ path: ["hook", "test"],
95
+ summary: "Run a hook now on what was saved last in this research (or one operation you name), wait for it and show what it printed",
96
+ group: "setup",
97
+ tree: true,
98
+ args: [{ name: "name", description: "the hook's folder name", required: true }],
99
+ options: [
100
+ { name: "last", type: "string", value: "<n>", description: "the last n operations saved (default 5)" },
101
+ { name: "op", type: "string", value: "<op>", description: "one operation instead, e.g. person.add (with --target)" },
102
+ { name: "target", type: "string", value: "<ID>", description: "the record it concerns, e.g. P0001" },
103
+ ],
104
+ examples: ["strom hook test telegram", "strom hook test telegram --op person.add --target P0001"],
105
+ run(ctx, { args, opts }) {
106
+ const s = shared(ctx);
107
+ const hook = loadHook(s, args[0]);
108
+ const tree = ctx.tree();
109
+ const n = opts.last === undefined ? 5 : Number(opts.last);
110
+ if (!Number.isInteger(n) || n < 1)
111
+ throw new UsageError("--last must be a positive number");
112
+ const all = typeof opts.op === "string"
113
+ ? [{ op: opts.op, targets: typeof opts.target === "string" ? [opts.target] : [], summary: `(a test of ${opts.op})`, at: new Date().toISOString(), by: "user" }]
114
+ : lastOps(tree.root, n);
115
+ const events = forHook(hook, all);
116
+ if (!events.length)
117
+ return {
118
+ text: `${hook.name} wants none of these: ${[...new Set(all.map((e) => e.op))].join(", ") || "nothing saved yet"} (its events: ${(hook.manifest.events ?? ["*"]).join(", ")})`,
119
+ data: { hook: hook.name, events: [] },
120
+ };
121
+ const r = runHookNow(hook, prependPath(ctx.env, shimDir(tree)), {
122
+ interface: HOOK_INTERFACE,
123
+ hook: hook.name,
124
+ tree: { name: tree.config.name, id: tree.config.id, root: tree.root, lang: tree.lang },
125
+ commit: "test",
126
+ at: new Date().toISOString(),
127
+ events,
128
+ });
129
+ const ok = !r.error && r.status === 0;
130
+ return {
131
+ text: lines(`${hook.name}: ${events.length} operation(s) — ${ok ? "ended well" : `failed: ${r.error ?? `exit status ${r.status}`}`}`, r.output
132
+ ? r.output
133
+ .split("\n")
134
+ .map((l) => ` ${l}`)
135
+ .join("\n")
136
+ : undefined),
137
+ data: { hook: hook.name, events, status: r.status, ...(r.error ? { error: r.error } : {}), output: r.output },
138
+ exitCode: ok ? 0 : 1,
139
+ };
140
+ },
141
+ });
@@ -19,5 +19,6 @@ import "./story.js";
19
19
  import "./output.js";
20
20
  import "./session.js";
21
21
  import "./gates.js";
22
+ import "./hooks.js";
22
23
  import "./checks.js";
23
24
  import "./setup.js";
@@ -67,8 +67,8 @@ function groupTask(tree, inputs, research, imported, folder) {
67
67
  return create(tree, "task", { level: "intake", priority: 4, what, where: ids, why, doneWhen, subject: ids, research, state: "open", origin: tree.actor }, (id) => `+${id} task "${truncate(what, 60)}"`);
68
68
  }
69
69
  /** More images than this in one folder look like the scans of a book, not family documents. */
70
- const BOOK_OF_SCANS = 20;
71
- const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".jp2", ".gif", ".webp", ".heic"]);
70
+ export const BOOK_OF_SCANS = 20;
71
+ export const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".jp2", ".gif", ".webp", ".heic"]);
72
72
  register({
73
73
  path: ["intake"],
74
74
  summary: "Take in material to research from: folders, files, a family tree (GEDCOM / Strom JSON), or text",
@@ -3,12 +3,16 @@ import { register } from "../cli/registry.js";
3
3
  import { lines, table } from "../cli/format.js";
4
4
  import { addPerson, addResearch } from "../core/actions.js";
5
5
  import { applyFrontier, researchProposals } from "./session.js";
6
+ import { frontier } from "../core/frontier.js";
7
+ import { parkedWhy } from "./tasks.js";
8
+ import { ui } from "../cli/ui.js";
6
9
  import { phrase } from "../core/phrases.js";
7
10
  import { UsageError } from "../core/errors.js";
8
11
  import { REVIEW_SCOPES } from "../core/model.js";
9
12
  import { ancestorGenerations, displayName, label, lifespan, parentsOf, resolvePerson } from "../core/people.js";
10
13
  import { foldText } from "../core/text.js";
11
14
  import { update } from "../core/records.js";
15
+ import { isAgent } from "../core/which.js";
12
16
  function int(v, name) {
13
17
  if (v === undefined)
14
18
  return undefined;
@@ -175,22 +179,31 @@ register({
175
179
  else if (opts.model)
176
180
  throw new UsageError("--model goes with --reread");
177
181
  const review = { scope: scope, ...(reread ? { reread } : {}) };
182
+ const lang = tree.lang;
178
183
  // one review research per person: again later, it goes on where it was
179
184
  const existing = tree.list("research").find((r) => r.direction === "person" && r.focus === p.id && r.review);
180
- let research;
181
- if (existing)
182
- research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
183
- op: "research.edit",
184
- summary: `${existing.id} review again: ${scope}${reread ? `, second reading with ${reread}` : ""}`,
185
- });
186
- else {
187
- const r = addResearch(tree, { name: phrase(tree.lang, "review.research", { name: displayName(p) }), focus: p.id, direction: "person" });
188
- research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${scope}${reread ? `, second reading with ${reread}` : ""}` });
185
+ // What it would propose, asked before anything is written: with nothing to do, no research is made for it.
186
+ const draft = existing
187
+ ? { ...existing, state: "active", review }
188
+ : { id: "G?", type: "research", name: phrase(lang, "review.research", { name: displayName(p) }), focus: p.id, direction: "person", state: "active", review, notes: [] };
189
+ const planned = researchProposals(tree, draft).map((x) => x.proposal);
190
+ let research = draft;
191
+ let tasks = [];
192
+ if (planned.length && !tree.dryRun) {
193
+ if (existing)
194
+ research = update(tree, existing.id, "research", (r) => ({ ...r, state: "active", review }), {
195
+ op: "research.edit",
196
+ summary: `${existing.id} review again: ${scope}${reread ? `, second reading with ${reread}` : ""}`,
197
+ });
198
+ else {
199
+ const r = addResearch(tree, { name: draft.name, focus: p.id, direction: "person" });
200
+ research = update(tree, r.id, "research", (x) => ({ ...x, review }), { op: "research.edit", summary: `${r.id} review: ${scope}${reread ? `, second reading with ${reread}` : ""}` });
201
+ }
202
+ tasks = applyFrontier(tree, research)
203
+ .map((id) => tree.get(id))
204
+ .filter((t) => !!t);
189
205
  }
190
- const planned = tree.dryRun ? researchProposals(tree, research).map((x) => x.proposal) : [];
191
- const created = tree.dryRun ? [] : applyFrontier(tree, research);
192
- const tasks = created.map((id) => tree.get(id)).filter((t) => !!t);
193
- const open = tree.list("task").filter((t) => t.research === research.id && ["open", "doing"].includes(t.state));
206
+ const open = existing ? tree.list("task").filter((t) => t.research === existing.id && ["open", "doing"].includes(t.state)) : [];
194
207
  // what a session costs here, from those so far: the user decides with the price in view
195
208
  const costs = tree
196
209
  .list("session")
@@ -198,8 +211,34 @@ register({
198
211
  .map((s) => s.metrics?.costUsd)
199
212
  .filter((c) => typeof c === "number");
200
213
  const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : undefined;
201
- const list = tree.dryRun ? planned.map((t) => ` would add: ${t.level} · ${t.what}`) : tasks.map((t) => ` ${t.id} ${t.level} · ${t.what}`);
202
- const text = lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined, "", list.length ? `${label(p)} — ${tree.dryRun ? planned.length : tasks.length} new task(s):` : `${label(p)} — nothing new to review${open.length ? `; ${open.length} task(s) of the review still open` : ""}`, ...list, open.length ? `${open.length} open in ${research.id}, about ${open.length} session(s)${avg !== undefined ? ` — sessions here cost $${avg.toFixed(2)} on average` : ""}` : undefined, open.length ? `next strom run --research ${research.id} (the agent alone) · or in a conversation: strom session start --research ${research.id}` : undefined);
203
- return { text, data: { research, created: tasks, planned, open: open.map((t) => t.id), ...(avg !== undefined ? { avgCostUsd: avg } : {}) } };
214
+ // A person at a terminal (the menu) reads the tasks and what comes next, not the operations and commands.
215
+ const human = ctx.io.tty && !isAgent(ctx.env);
216
+ const list = tree.dryRun
217
+ ? planned.map((t) => ui(lang, "ui.review.would", { level: t.level, what: t.what }))
218
+ : tasks.map((t) => (human ? ` • ${t.what}` : ` ${t.id} ${t.level} · ${t.what}`));
219
+ // Nothing new: why — the work that covers them already (a parked task says how to go on), a search done in vain.
220
+ const why = list.length ? [] : coveredLines(tree, draft, lang, human);
221
+ const inReview = existing ?? (planned.length && !tree.dryRun ? research : undefined);
222
+ const text = lines(...(human ? [] : tree.written.map((o) => o.summary)), tree.dryRun ? ui(lang, "ui.review.dry") : undefined, (tree.written.length && !human) || tree.dryRun ? "" : undefined, list.length ? ui(lang, "ui.review.new", { name: label(p), n: list.length }) : ui(lang, "ui.review.nothing", { name: label(p) }), ...list, ...why, open.length || tasks.length
223
+ ? ui(lang, "ui.review.open", { n: open.length || tasks.length, research: inReview.id }) + (avg !== undefined ? ui(lang, "ui.review.cost", { cost: avg.toFixed(2) }) : "")
224
+ : undefined, (open.length || tasks.length) && inReview ? ui(lang, human ? "ui.review.next.human" : "ui.review.next", { research: inReview.id }) : undefined);
225
+ return { text, data: { research: inReview ?? null, created: tasks, planned, open: open.map((t) => t.id), ...(why.length ? { why } : {}), ...(avg !== undefined ? { avgCostUsd: avg } : {}) } };
204
226
  },
205
227
  });
228
+ /** Why a review found nothing new, person by person: the work that covers them already, or a search done in vain. */
229
+ function coveredLines(tree, research, lang, human = false) {
230
+ const out = [];
231
+ for (const item of frontier(tree, research)) {
232
+ const name = label(item.person);
233
+ const t = item.coveredBy ? tree.get(item.coveredBy) : undefined;
234
+ if (t) {
235
+ const why = parkedWhy(tree, t);
236
+ out.push(ui(lang, "ui.review.covered", { name, task: t.id, state: ui(lang, `ui.review.state.${t.state}`) + (why ? `: ${why}` : ""), what: t.what }));
237
+ if (t.state === "parked")
238
+ out.push(ui(lang, human ? "ui.review.wake.human" : "ui.review.wake", { task: t.id }));
239
+ }
240
+ else if (item.exhausted?.length)
241
+ out.push(ui(lang, "ui.review.exhausted", { name, tasks: item.exhausted.join(", ") }));
242
+ }
243
+ return out;
244
+ }
@@ -37,6 +37,7 @@ import { guard } from "../core/guard.js";
37
37
  import { hasErrors } from "../core/check.js";
38
38
  import { Tree } from "../core/tree.js";
39
39
  import { treeBrowserConnectors } from "../core/connector.js";
40
+ import { browserNote } from "./connectors.js";
40
41
  import { reviewProposals } from "../core/review.js";
41
42
  function written(tree) {
42
43
  return lines(...tree.written.map((o) => o.summary), tree.dryRun ? "(dry run — nothing written)" : undefined);
@@ -479,6 +480,10 @@ register({
479
480
  out(ui(lang, "ui.run.browser", { name: c.manifest.title ?? c.name, hosts: c.manifest.hosts.join(", ") }));
480
481
  if (browser.length && /haiku/i.test(models.lead ?? ""))
481
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);
482
487
  if (permissions === "full")
483
488
  out(ui(lang, "ui.run.full"));
484
489
  // The gate holds a run that goes on by itself (--loop, --until). Tasks the user starts themselves (one, --max n,
@@ -598,7 +603,7 @@ register({
598
603
  shared: ctx.settings.shared()?.value,
599
604
  ...(models.lead ? { model: models.lead } : {}),
600
605
  ...(opts.interactive ? { interactive: true } : {}),
601
- ...(runnerId === "claude" ? { chrome: browser.length > 0, permissions } : {}),
606
+ ...(runnerId === "claude" ? { chrome: browser.length > 0, permissions, remote: ctx.settings.agentRemote() } : {}),
602
607
  ...(extra?.length ? { extraArgs: extra } : {}),
603
608
  onProgress: (l) => out(` · ${l}`),
604
609
  signal: stop.signal,
@@ -624,10 +629,11 @@ register({
624
629
  const back = after.get(task.id);
625
630
  const idle = back && back.state === "open" ? idleSessions(after, task.id) : 0;
626
631
  if (idle >= MAX_IDLE_SESSIONS) {
627
- 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 }), {
628
634
  op: "task.park",
629
635
  summary: `${task.id} park: ${idle} sessions without a result`,
630
- reason: `${idle} sessions in a row recorded nothing for it — needs a look (strom task show ${task.id})`,
636
+ reason: why,
631
637
  });
632
638
  out(ui(lang, "ui.run.parked", { task: task.id, n: idle }));
633
639
  }
@@ -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";
@@ -31,13 +32,16 @@ import { check } from "../core/check.js";
31
32
  import { assertIntact, verifyFull } from "../core/integrity.js";
32
33
  import { ensurePluginsDir } from "../core/connector.js";
33
34
  import { ensureGatesDir, loadGate } from "../core/gate.js";
34
- import { downloadsDir } from "../core/browser.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";
35
38
  export const SHARED_DIRS = ["media", "catalog", "tools", "cache", "inbox"];
36
39
  export function ensureShared(dir) {
37
40
  for (const d of SHARED_DIRS)
38
41
  fs.mkdirSync(path.join(dir, d), { recursive: true });
39
42
  ensurePluginsDir(dir);
40
43
  ensureGatesDir(dir);
44
+ ensureHooksDir(dir);
41
45
  }
42
46
  register({
43
47
  path: ["setup"],
@@ -69,6 +73,12 @@ register({
69
73
  set: `strom setup --home "${ctx.display(suggestedHome)}" --yes`,
70
74
  },
71
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);
72
82
  let home = flags.home ? ctx.resolvePath(flags.home) : suggestedHome;
73
83
  let shared = flags.shared ? ctx.resolvePath(flags.shared) : undefined;
74
84
  let trees = flags.trees ? ctx.resolvePath(flags.trees) : undefined;
@@ -189,6 +199,19 @@ function diagnose(ctx) {
189
199
  if (model)
190
200
  add("model", "ok", model);
191
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
+ }
192
215
  // For a person: the shortcut on the desktop, the Strom app.
193
216
  const desktop = desktopDir(ctx.env);
194
217
  const shortcut = [`${shortcutName(lang)}.command`, `${shortcutName(lang)}.lnk`, `${shortcutName(lang)}.cmd`, "strom-research.desktop"].some((f) => fs.existsSync(path.join(desktop, f)));
@@ -391,11 +414,33 @@ function raises(now, value) {
391
414
  return PERMISSION_LEVELS.indexOf(next) > PERMISSION_LEVELS.indexOf(now);
392
415
  }
393
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
+ }
394
435
  function setUserSetting(ctx, key, value) {
395
436
  const s = ctx.settings;
437
+ guardResearchFolder(ctx, key, value === undefined ? undefined : String(value));
396
438
  // Loosening the agent's permissions is the user's decision alone.
397
439
  if (key === "agent.permissions" && raises(s.agentPermissions(), value))
398
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"));
399
444
  // Asking before a connector runs is the user's safeguard: only they take it away.
400
445
  if (key === "connectors.consent" && value !== "on" && s.connectorsConsent())
401
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"));
@@ -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");