strom-research 1.2.0 → 1.4.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 (46) hide show
  1. package/README.md +4 -1
  2. package/assets/lang/cs.json +164 -7
  3. package/assets/lang/de.json +164 -7
  4. package/assets/plugins/README.md +8 -1
  5. package/assets/plugins/gates/README.md +1 -1
  6. package/assets/plugins/hooks/README.md +91 -0
  7. package/dist/agents/files.js +142 -99
  8. package/dist/agents/global.js +83 -6
  9. package/dist/agents/launch.js +12 -5
  10. package/dist/agents/profiles.js +13 -2
  11. package/dist/cli/main.js +17 -1
  12. package/dist/cli/menu-parts.js +140 -0
  13. package/dist/cli/menu-research.js +194 -0
  14. package/dist/cli/menu-settings.js +273 -0
  15. package/dist/cli/menu-waiting.js +79 -0
  16. package/dist/cli/menu.js +91 -97
  17. package/dist/cli/registry.js +1 -1
  18. package/dist/cli/ui.js +162 -6
  19. package/dist/cli/wizard.js +48 -2
  20. package/dist/commands/browse.js +2 -2
  21. package/dist/commands/connectors.js +25 -2
  22. package/dist/commands/hooks.js +141 -0
  23. package/dist/commands/index.js +1 -0
  24. package/dist/commands/intake.js +2 -2
  25. package/dist/commands/meta.js +6 -2
  26. package/dist/commands/research.js +55 -16
  27. package/dist/commands/session.js +15 -7
  28. package/dist/commands/setup.js +51 -2
  29. package/dist/commands/start.js +9 -4
  30. package/dist/commands/tasks.js +41 -4
  31. package/dist/core/browser.js +50 -0
  32. package/dist/core/config.js +7 -1
  33. package/dist/core/hooks.js +210 -0
  34. package/dist/core/install.js +3 -2
  35. package/dist/core/media.js +21 -7
  36. package/dist/core/phrases.js +1 -0
  37. package/dist/core/relocate.js +111 -0
  38. package/dist/core/shortcut.js +1 -4
  39. package/dist/core/tree.js +19 -2
  40. package/dist/core/which.js +18 -0
  41. package/dist/runners/claude.js +3 -0
  42. package/dist/runners/codex.js +14 -5
  43. package/dist/runners/grok.js +108 -0
  44. package/dist/runners/index.js +2 -0
  45. package/dist/runners/opencode.js +8 -4
  46. package/package.json +1 -1
@@ -13,10 +13,11 @@ import { PROFILES } from "../agents/profiles.js";
13
13
  import { conversationArgs } from "../agents/launch.js";
14
14
  import { globalTargets, installGlobal, isInstalled, uninstallGlobal } from "../agents/global.js";
15
15
  import { syncAgentFiles } from "../agents/files.js";
16
- import { AGENTS, findAgent, isAgent } from "../core/which.js";
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
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";
@@ -62,7 +63,7 @@ register({
62
63
  "(agent.permissions: ask, auto, full), your model, and asks you what matters. Where you talk (agent.where): the\n" +
63
64
  "agent's desktop app when it is installed (Claude, ChatGPT/Codex, OpenCode — the tree folder and the first message\n" +
64
65
  "filled in; you confirm the folder and send it), else its CLI in this terminal (Claude Code, Codex, Antigravity,\n" +
65
- "OpenCode). The first message is yours to give (--say); without it the agent reports where things\n" +
66
+ "OpenCode, Grok). The first message is yours to give (--say); without it the agent reports where things\n" +
66
67
  "stand and suggests what next.",
67
68
  options: [
68
69
  { name: "say", type: "string", value: "<text>", description: "your first message to the agent" },
@@ -123,6 +124,7 @@ register({
123
124
  return { text: ui(lang, opened ? "ui.chat.handover" : "ui.chat.handover.failed", { agent: profile.name }), data: { agent, handover: opened ? "window" : "none", cwd: tree.root } };
124
125
  }
125
126
  const base = {
127
+ root: tree.root,
126
128
  kickoff,
127
129
  level,
128
130
  model: ctx.settings.models(agent, tree.config).lead,
@@ -131,7 +133,7 @@ register({
131
133
  // its research when there is one, the day.
132
134
  name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
133
135
  shared,
134
- ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
136
+ ...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
135
137
  };
136
138
  const args = conversationArgs(agent, base);
137
139
  if (opts.print)
@@ -147,10 +149,13 @@ register({
147
149
  const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
148
150
  const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
149
151
  ctx.io.stdout(ui(lang, "ui.chat.open", { agent: profile.name, exit: profile.exit }) + "\n");
152
+ const browserSays = browserNote(ctx, tree, agent);
153
+ if (browserSays)
154
+ ctx.io.stdout(browserSays + "\n");
150
155
  // Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
151
156
  if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
152
157
  ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
153
- const env = { ...prependPath(ctx.env, shimDir(tree)), STROM_WORKER: worker, ...(base.model ? { STROM_MODEL: base.model } : {}) };
158
+ const env = { ...prependPath(withoutAgentMarks(ctx.env), shimDir(tree)), STROM_WORKER: worker, ...(base.model ? { STROM_MODEL: base.model } : {}) };
154
159
  delete env.STROM_HANDOVER;
155
160
  let code;
156
161
  try {
@@ -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
  }, {
@@ -41,6 +41,56 @@ export const CHROME_ALLOW = [
41
41
  export const CHROME_DENY = ["file_upload", "upload_image", "shortcuts_execute", "gif_creator", "browser_batch"].map((t) => `mcp__claude-in-chrome__${t}`);
42
42
  /** The permission rule of Claude in Chrome for one site. */
43
43
  export const chromeDomain = (host) => `ClaudeInChromeDomain(${host.replace(/^\*\./, "").toLowerCase()})`;
44
+ /** Claude in Chrome in the Chrome Web Store: the extension Claude Code's browser tools work through. */
45
+ export const CLAUDE_IN_CHROME_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
46
+ export const CLAUDE_IN_CHROME_URL = `https://chromewebstore.google.com/detail/${CLAUDE_IN_CHROME_ID}`;
47
+ /** The folders of the browsers' profiles (Chrome, Edge, Brave take extensions from the Chrome Web Store). */
48
+ function browserDataDirs(env, platform) {
49
+ const home = userHome(env);
50
+ if (platform === "darwin") {
51
+ const base = path.join(home, "Library", "Application Support");
52
+ return [
53
+ { browser: "Chrome", dir: path.join(base, "Google", "Chrome") },
54
+ { browser: "Edge", dir: path.join(base, "Microsoft Edge") },
55
+ { browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser") },
56
+ ];
57
+ }
58
+ if (platform === "win32") {
59
+ const base = env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
60
+ return [
61
+ { browser: "Chrome", dir: path.join(base, "Google", "Chrome", "User Data") },
62
+ { browser: "Edge", dir: path.join(base, "Microsoft", "Edge", "User Data") },
63
+ { browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser", "User Data") },
64
+ ];
65
+ }
66
+ const base = env.XDG_CONFIG_HOME ?? path.join(home, ".config");
67
+ return [
68
+ { browser: "Chrome", dir: path.join(base, "google-chrome") },
69
+ { browser: "Edge", dir: path.join(base, "microsoft-edge") },
70
+ { browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser") },
71
+ ];
72
+ }
73
+ /**
74
+ * Is Claude in Chrome installed — in which browsers? Only the extension's folder in the profiles is looked for (its
75
+ * name), nothing else of the browser is read. Whether it is signed in and connected strom cannot see.
76
+ */
77
+ export function claudeInChrome(env, platform = process.platform) {
78
+ const browsers = [];
79
+ const extension = [];
80
+ for (const { browser, dir } of browserDataDirs(env, platform)) {
81
+ let profiles;
82
+ try {
83
+ profiles = fs.readdirSync(dir).filter((n) => n === "Default" || /^Profile \d+$/u.test(n));
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ browsers.push(browser);
89
+ if (profiles.some((p) => fs.existsSync(path.join(dir, p, "Extensions", CLAUDE_IN_CHROME_ID))))
90
+ extension.push(browser);
91
+ }
92
+ return { browsers, extension };
93
+ }
44
94
  /** The folder a browser saves downloads into by default: ~/Downloads, or what the desktop names it (Linux). */
45
95
  export function downloadsDir(env, platform = process.platform) {
46
96
  const home = userHome(env);
@@ -24,7 +24,7 @@ export const SETTINGS = [
24
24
  { key: "shared", env: "STROM_SHARED", tree: false, kind: "path", description: "shared data: scans, catalog, tools (default <home>/shared)" },
25
25
  { key: "trees", env: "STROM_TREES", tree: false, kind: "path", description: "folder holding the trees (default <home>)" },
26
26
  { key: "lang", env: "STROM_LANG", tree: true, kind: "lang", description: "research language: the agent talks and writes in it" },
27
- { key: "agent", env: "STROM_AGENT", tree: true, kind: "agent", description: "AI agent doing the research: claude, codex, antigravity, opencode" },
27
+ { key: "agent", env: "STROM_AGENT", tree: true, kind: "agent", description: "AI agent doing the research: claude, codex, antigravity, opencode, grok" },
28
28
  ...TIERS.map((t) => ({
29
29
  key: `model.${t}`,
30
30
  env: t === "lead" ? "STROM_MODEL" : `STROM_MODEL_${t.toUpperCase()}`,
@@ -54,6 +54,7 @@ export const SETTINGS = [
54
54
  // Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
55
55
  { key: "agent.permissions", env: "", tree: false, kind: "choice", choices: PERMISSION_LEVELS, description: "what the agent may do without asking you: ask (what the tree allows; anything else it asks), auto (default: what the tree allows; anything else the agent's own review decides, asking only when risky), full (everything but what the tree denies) — only you raise it" },
56
56
  { key: "agent.where", env: "STROM_AGENT_WHERE", tree: false, kind: "choice", choices: ["app", "terminal"], description: "where you talk with the agent: app (its desktop app — the easiest), terminal (its CLI) — unset: the app when it is installed" },
57
+ { key: "agent.remote", env: "", tree: false, kind: "choice", choices: ["on", "off"], description: "the Claude Code sessions strom starts (strom run, strom chat in the terminal) with Remote Control: on — follow and steer them from claude.ai or the Claude app on your phone; off (default) — only you turn it on" },
57
58
  { key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
58
59
  { key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
59
60
  { key: "strom.app.url", env: "STROM_APP_URL", tree: false, kind: "url", description: "another copy of the Strom app to open instead of https://stromapp.info/run/ — its beta (https://beta.stromapp.info/run/), its development (http://127.0.0.1:8080/); installed from a browser, that copy opens as its own app" },
@@ -69,6 +70,7 @@ const FIELDS = {
69
70
  "browser.downloads": "browserDownloads",
70
71
  "agent.permissions": "agentPermissions",
71
72
  "agent.where": "agentWhere",
73
+ "agent.remote": "agentRemote",
72
74
  "strom.app": "stromApp",
73
75
  "strom.app.url": "stromAppUrl",
74
76
  "main.person": "mainPerson",
@@ -334,6 +336,10 @@ export class Settings {
334
336
  downloads() {
335
337
  return String(this.resolve("browser.downloads")?.value ?? downloadsDir(this.env));
336
338
  }
339
+ /** Claude Code sessions strom starts with Remote Control (agent.remote: on). */
340
+ agentRemote() {
341
+ return this.resolve("agent.remote")?.value === "on";
342
+ }
337
343
  /** Where the user wants to talk with the agent, if they said (env, else the config); see whereToTalk. */
338
344
  agentWhere() {
339
345
  const r = this.resolve("agent.where");
@@ -0,0 +1,210 @@
1
+ // Hooks: the user's programs told of what was saved into a research. After
2
+ // every commit of a research, strom starts each hook the user turned on
3
+ // (`strom hook on <name>`) in the background — never waiting for it, never
4
+ // failing because of it — with the operations that commit saved: a person
5
+ // added, a fact, a task done, a session closed… A hook filters them
6
+ // (`events` in hook.json, or itself), may read more through strom (`strom
7
+ // person card P0002`) and does with it what the user wants: a message to
8
+ // their phone, a log, a backup.
9
+ //
10
+ // A hook is a folder in the plugins folder, <shared>/plugins/hooks/<name>/,
11
+ // with hook.json ({"interface": 1, "command": [...], "events": [...]}) and its
12
+ // program (interface: assets/plugins/hooks/README.md, copied next to the
13
+ // hooks). The event comes as one JSON document on the program's stdin; what it
14
+ // prints goes to hook.log in its folder. A hook runs only when the user turned
15
+ // it on — a folder an agent put there does nothing by itself.
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { spawn, spawnSync } from "node:child_process";
19
+ import { StromError, UsageError } from "./errors.js";
20
+ import { NAME_RE, pluginsDir } from "./connector.js";
21
+ import { readAsset } from "./assets.js";
22
+ export const HOOK_INTERFACE = 1;
23
+ const MANIFEST = "hook.json";
24
+ /** How long `strom hook test` waits for a hook. */
25
+ const DEFAULT_TIMEOUT_S = 60;
26
+ /** Event files older than this are cleared away (a hook reads its own at once). */
27
+ const KEEP_MS = 24 * 3600_000;
28
+ /** The hook's output, appended; cut to its last part when it grows past this. */
29
+ const LOG_MAX = 1024 * 1024;
30
+ export function hooksDir(shared) {
31
+ return path.join(pluginsDir(shared), "hooks");
32
+ }
33
+ /** The hooks folder with its interface (refreshed). */
34
+ export function ensureHooksDir(shared) {
35
+ const dir = hooksDir(shared);
36
+ try {
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ const text = readAsset("plugins", "hooks", "README.md");
39
+ const file = path.join(dir, "README.md");
40
+ let old;
41
+ try {
42
+ old = fs.readFileSync(file, "utf8");
43
+ }
44
+ catch {
45
+ old = undefined;
46
+ }
47
+ if (text !== undefined && old !== text)
48
+ fs.writeFileSync(file, text);
49
+ }
50
+ catch (err) {
51
+ if (!["EACCES", "EPERM", "EROFS"].includes(err?.code ?? ""))
52
+ throw err;
53
+ }
54
+ return dir;
55
+ }
56
+ export function listHooks(shared) {
57
+ let names = [];
58
+ try {
59
+ names = fs
60
+ .readdirSync(hooksDir(shared), { withFileTypes: true })
61
+ .filter((d) => d.isDirectory() && NAME_RE.test(d.name))
62
+ .map((d) => d.name);
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ const out = [];
68
+ for (const name of names.sort()) {
69
+ try {
70
+ out.push(loadHook(shared, name));
71
+ }
72
+ catch {
73
+ // not a hook (yet): `strom hook test <name>` says why
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ export function loadHook(shared, name) {
79
+ if (!NAME_RE.test(name))
80
+ throw new UsageError(`invalid hook name "${name}"`, { hint: "lowercase letters, digits and dashes, e.g. telegram" });
81
+ const dir = path.join(hooksDir(shared), name);
82
+ const file = path.join(dir, MANIFEST);
83
+ if (!fs.existsSync(file))
84
+ throw new StromError(`no hook "${name}" (no ${file})`, { hint: `the hooks here: strom hook list — a hook is a folder in ${hooksDir(shared)} with ${MANIFEST}` });
85
+ let m;
86
+ try {
87
+ m = JSON.parse(fs.readFileSync(file, "utf8"));
88
+ }
89
+ catch (err) {
90
+ throw new StromError(`hook "${name}": ${MANIFEST} is not valid JSON (${err.message})`);
91
+ }
92
+ if (m.interface !== HOOK_INTERFACE)
93
+ throw new StromError(`hook "${name}" is for interface ${m.interface}, this strom knows ${HOOK_INTERFACE}`, {
94
+ hint: m.interface > HOOK_INTERFACE ? "update strom: strom update" : `see ${path.join(hooksDir(shared), "README.md")}`,
95
+ });
96
+ if (!Array.isArray(m.command) || !m.command.length || !m.command.every((c) => typeof c === "string" && c))
97
+ throw new StromError(`hook "${name}": "command" must be a list of strings, e.g. ["node", "hook.ts"]`);
98
+ if (m.events !== undefined && (!Array.isArray(m.events) || !m.events.every((e) => typeof e === "string" && e)))
99
+ throw new StromError(`hook "${name}": "events" must be a list like ["person.add", "session.*"]`);
100
+ return { name, dir, manifest: m };
101
+ }
102
+ /** Does the hook want this operation? "*" all, "person.*" a kind, "person.add" one. */
103
+ export function wants(m, op) {
104
+ return (m.events ?? ["*"]).some((p) => p === "*" || p === op || (p.endsWith(".*") && op.startsWith(p.slice(0, -1))));
105
+ }
106
+ /** The operations of a commit the hook wants, or none. */
107
+ export function forHook(hook, events) {
108
+ return events.filter((e) => wants(hook.manifest, e.op));
109
+ }
110
+ function program(hook) {
111
+ const [cmd, ...args] = hook.manifest.command;
112
+ return { cmd: cmd === "node" ? process.execPath : cmd, args };
113
+ }
114
+ function hookEnv(env, hook, payload, file) {
115
+ return {
116
+ ...env,
117
+ STROM_HOOK: hook.name,
118
+ STROM_TREE: payload.tree.root,
119
+ STROM_LANG: payload.tree.lang,
120
+ STROM_EVENT_FILE: file,
121
+ // what it runs of strom reads the research the event came from, as the user would
122
+ STROM_NONINTERACTIVE: "1",
123
+ };
124
+ }
125
+ /** The event as a file (the hook's stdin, and STROM_EVENT_FILE); old ones are cleared away. */
126
+ function eventFile(hook, payload) {
127
+ const dir = path.join(hook.dir, ".events");
128
+ fs.mkdirSync(dir, { recursive: true });
129
+ try {
130
+ for (const f of fs.readdirSync(dir)) {
131
+ const p = path.join(dir, f);
132
+ if (Date.now() - fs.statSync(p).mtimeMs > KEEP_MS)
133
+ fs.rmSync(p, { force: true });
134
+ }
135
+ }
136
+ catch {
137
+ // another process cleared it
138
+ }
139
+ const file = path.join(dir, `${payload.at.replace(/[:.]/g, "-")}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`);
140
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n");
141
+ return file;
142
+ }
143
+ /** Its log, cut to its last part when it has grown too big. */
144
+ function logFd(hook) {
145
+ const file = path.join(hook.dir, "hook.log");
146
+ try {
147
+ if (fs.statSync(file).size > LOG_MAX) {
148
+ const tail = fs.readFileSync(file).subarray(-LOG_MAX / 2);
149
+ fs.writeFileSync(file, tail);
150
+ }
151
+ }
152
+ catch {
153
+ // no log yet
154
+ }
155
+ return fs.openSync(file, "a");
156
+ }
157
+ /**
158
+ * Tell the hooks the user turned on what a commit saved — in the background: strom neither waits for them nor
159
+ * fails because of them. A hook's own writes (it may run strom) tell no hook again.
160
+ */
161
+ export function fireHooks(shared, names, env, payload) {
162
+ if (!shared || !names?.length || !payload.events.length || env.STROM_HOOK)
163
+ return;
164
+ for (const name of names) {
165
+ try {
166
+ const hook = loadHook(shared, name);
167
+ const events = forHook(hook, payload.events);
168
+ if (!events.length)
169
+ continue;
170
+ const full = { ...payload, hook: name, events };
171
+ const file = eventFile(hook, full);
172
+ let input;
173
+ let log;
174
+ try {
175
+ input = fs.openSync(file, "r");
176
+ log = logFd(hook);
177
+ const { cmd, args } = program(hook);
178
+ const child = spawn(cmd, args, { cwd: hook.dir, env: hookEnv(env, hook, full, file), stdio: [input, log, log], detached: true, windowsHide: true });
179
+ child.on("error", () => undefined);
180
+ child.unref();
181
+ }
182
+ finally {
183
+ // the child has its own copies; ours are closed whatever happened
184
+ for (const fd of [input, log])
185
+ if (fd !== undefined)
186
+ fs.closeSync(fd);
187
+ }
188
+ }
189
+ catch {
190
+ // a hook that is gone or broken never stops the research: strom hook test <name> says why
191
+ }
192
+ }
193
+ }
194
+ /** Run a hook now and wait for it (strom hook test): what it printed and how it ended. */
195
+ export function runHookNow(hook, env, payload) {
196
+ const file = eventFile(hook, payload);
197
+ const { cmd, args } = program(hook);
198
+ const r = spawnSync(cmd, args, {
199
+ cwd: hook.dir,
200
+ env: hookEnv(env, hook, payload, file),
201
+ input: fs.readFileSync(file),
202
+ encoding: "utf8",
203
+ timeout: (hook.manifest.timeout ?? DEFAULT_TIMEOUT_S) * 1000,
204
+ windowsHide: true,
205
+ });
206
+ const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
207
+ if (r.error)
208
+ return { status: null, output, error: r.error.code === "ETIMEDOUT" ? `no end within ${hook.manifest.timeout ?? DEFAULT_TIMEOUT_S} s` : r.error.message };
209
+ return { status: r.status, output };
210
+ }
@@ -22,7 +22,8 @@ export function runInstaller(inst, env) {
22
22
  */
23
23
  export function agentBinDirs(env, platform = process.platform) {
24
24
  const home = userHome(env);
25
+ // Grok Build's installer: ~/.grok/bin (on PATH through the shell's profile; Windows: added by hand)
25
26
  if (platform === "win32")
26
- return [path.join(home, ".local", "bin"), path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "npm")];
27
- return [path.join(home, ".local", "bin"), path.join(home, ".claude", "local")];
27
+ return [path.join(home, ".local", "bin"), path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "npm"), path.join(home, ".grok", "bin")];
28
+ return [path.join(home, ".local", "bin"), path.join(home, ".claude", "local"), path.join(home, ".grok", "bin")];
28
29
  }
@@ -64,22 +64,36 @@ const MIME = {
64
64
  export function mimeOf(file) {
65
65
  return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
66
66
  }
67
- /** Every file under the given paths (folders recursively), hidden files skipped. */
67
+ /**
68
+ * Every file under the given paths (folders recursively), hidden files skipped. What is inside a folder but cannot be
69
+ * read (a broken link or alias, a folder the system keeps to itself) is left out; the paths given must be there.
70
+ */
68
71
  export function collectFiles(paths) {
69
72
  const out = [];
70
- const walk = (p) => {
73
+ const walk = (p, given) => {
71
74
  const base = path.basename(p);
72
- if (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini")
75
+ if (!given && (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini"))
76
+ return;
77
+ let st;
78
+ let names = [];
79
+ try {
80
+ st = fs.statSync(p);
81
+ if (st.isDirectory())
82
+ names = fs.readdirSync(p).sort();
83
+ }
84
+ catch (err) {
85
+ if (given)
86
+ throw err;
73
87
  return;
74
- const st = fs.statSync(p);
88
+ }
75
89
  if (st.isDirectory())
76
- for (const e of fs.readdirSync(p).sort())
77
- walk(path.join(p, e));
90
+ for (const e of names)
91
+ walk(path.join(p, e), false);
78
92
  else if (st.isFile())
79
93
  out.push(p);
80
94
  };
81
95
  for (const p of paths)
82
- walk(p);
96
+ walk(p, true);
83
97
  return out;
84
98
  }
85
99
  const digitGroups = (file) => path.basename(file, path.extname(file)).match(/\d+/g) ?? [];
@@ -76,6 +76,7 @@ export const PHRASES = {
76
76
  "intake.done.imported": "the leads are checked for obvious mistakes (duplicates, impossible dates) and the first locate/link tasks exist",
77
77
  "intake.done.read": "what it adds to the tree is entered as leads citing it (strom source add … --kind family-tree --form authored --input {id}) — or the input is skipped with a reason (strom input skip {id} --reason …)",
78
78
  "intake.done": "every person and fact in it is recorded (a source if it is an original document, leads otherwise) and cited — or the input is skipped with a reason (strom input skip I… --reason …)",
79
+ "task.answer": "The user answered (to: {asked}): {answer}",
79
80
  };
80
81
  const catalogs = new Map();
81
82
  /**
@@ -0,0 +1,111 @@
1
+ // Moving the research to another folder (the setting `home`). The trees and the
2
+ // shared folder (connectors, hooks, gates, the inbox, big images) live under it:
3
+ // changing the setting alone would leave them behind — the trees gone from the
4
+ // list, the plugins and images not found. So the whole folder moves with it:
5
+ // renamed where it can be (one disk), else copied and the old one left for the
6
+ // user to delete. Never while somebody works in a tree, never into a folder
7
+ // that holds something already, never into itself.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { loadConsents, saveConsents } from "./connector.js";
11
+ import { liveHolder } from "./lock.js";
12
+ import { liveRunning } from "./live.js";
13
+ import { liveWorkers, runsAtWork } from "./workers.js";
14
+ /** Files a system leaves in any folder: a folder with only these is empty. */
15
+ const LITTER = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
16
+ /** What the top of a disk holds of the system's own: the research goes into a folder of its own there. */
17
+ const DISK = new Set([".Spotlight-V100", ".fseventsd", ".Trashes", ".TemporaryItems", ".DocumentRevisions-V100", "System Volume Information", "$RECYCLE.BIN"]);
18
+ function entries(dir) {
19
+ try {
20
+ return fs.readdirSync(dir).filter((n) => !LITTER.has(n));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ /** Is `inner` the folder `outer` or somewhere in it? (Windows: letters of any case are the same.) */
27
+ export function within(inner, outer, platform = process.platform) {
28
+ const [a, b] = platform === "win32" ? [inner.toLowerCase(), outer.toLowerCase()] : [inner, outer];
29
+ const rel = path.relative(b, a);
30
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
31
+ }
32
+ /** The same folder? (Windows: letters of any case are the same.) */
33
+ export function sameFolder(a, b, platform = process.platform) {
34
+ return within(path.resolve(a), path.resolve(b), platform) && within(path.resolve(b), path.resolve(a), platform);
35
+ }
36
+ /** Somebody at work in a tree: an agent present, a run, a command holding its lock, the live bridge. */
37
+ function atWork(root) {
38
+ return liveWorkers(root).length > 0 || runsAtWork(root).length > 0 || Boolean(liveHolder(path.join(root, ".strom", "tree.lock"))) || Boolean(liveRunning(root));
39
+ }
40
+ /** `trees`: the folders of the trees strom knows (those inside `from` move along). */
41
+ export function planMove(from, to, trees) {
42
+ const names = entries(from);
43
+ const inside = trees.filter((t) => within(t, from));
44
+ const busy = inside.filter(atWork).map((t) => path.basename(t));
45
+ // The top of a disk (its own hidden folders only): into a folder named like the old one there.
46
+ const there = entries(to);
47
+ const target = there.length && there.every((n) => DISK.has(n)) ? path.join(to, path.basename(from) || "Strom") : to;
48
+ const plan = { from, to: target, content: names.length > 0, trees: inside.map((t) => path.basename(t)), busy };
49
+ if (!plan.content)
50
+ return plan;
51
+ if (within(target, from) || within(from, target))
52
+ plan.problem = "inside";
53
+ else if (entries(target).length)
54
+ plan.problem = "notEmpty";
55
+ else if (busy.length)
56
+ plan.problem = "busy";
57
+ return plan;
58
+ }
59
+ /** Move the folder; "copied" when it had to be copied to another disk (the old one stays). */
60
+ export function moveHome(plan) {
61
+ fs.mkdirSync(path.dirname(plan.to), { recursive: true });
62
+ if (fs.existsSync(plan.to)) {
63
+ // empty but for what a system leaves (planMove said so)
64
+ for (const n of LITTER)
65
+ fs.rmSync(path.join(plan.to, n), { force: true });
66
+ fs.rmdirSync(plan.to);
67
+ }
68
+ try {
69
+ fs.renameSync(plan.from, plan.to);
70
+ return "moved";
71
+ }
72
+ catch (err) {
73
+ if (err.code !== "EXDEV")
74
+ throw err;
75
+ }
76
+ try {
77
+ fs.cpSync(plan.from, plan.to, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true, verbatimSymlinks: true });
78
+ }
79
+ catch (err) {
80
+ fs.rmSync(plan.to, { recursive: true, force: true }); // half a copy is no copy
81
+ throw err;
82
+ }
83
+ return "copied";
84
+ }
85
+ /**
86
+ * What pointed into the old folder points into the new one: the settings (the folders under it, the tree worked on) and
87
+ * the consents to connectors, which know a connector by its folder.
88
+ */
89
+ export function repointSettings(settings, env, from, to) {
90
+ const cfg = settings.config;
91
+ const move = (p) => (p && within(p, from) ? path.join(to, path.relative(from, p)) : p);
92
+ cfg.home = to;
93
+ if (cfg.shared)
94
+ cfg.shared = move(cfg.shared);
95
+ if (cfg.trees)
96
+ cfg.trees = move(cfg.trees);
97
+ if (cfg.currentTree)
98
+ cfg.currentTree = move(cfg.currentTree);
99
+ if (cfg.extraTrees)
100
+ cfg.extraTrees = cfg.extraTrees.map((p) => move(p));
101
+ settings.save();
102
+ const consents = loadConsents(env);
103
+ let changed = false;
104
+ for (const c of Object.values(consents.connectors))
105
+ if (c.dir && within(c.dir, from)) {
106
+ c.dir = move(c.dir);
107
+ changed = true;
108
+ }
109
+ if (changed)
110
+ saveConsents(env, consents);
111
+ }
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
8
  import { configDir, desktopDir, userHome } from "./paths.js";
9
9
  import { stromLauncher } from "./self.js";
10
+ import { isAgentMark } from "./which.js";
10
11
  import { assetPath } from "./assets.js";
11
12
  /** Strom Research's icon, as each system takes it. */
12
13
  export const ICON = { png: assetPath("icon", "icon-512.png"), ico: assetPath("icon", "icon.ico") };
@@ -96,10 +97,6 @@ export function createShortcut(name, env, platform = process.platform) {
96
97
  */
97
98
  /** One handover window at a time: another asked within this long is not opened. */
98
99
  export const HANDOVER_GAP_MS = 15_000;
99
- /** What says an agent runs strom (core/which.ts detectAgent) — not the user's own settings of an agent (…_HOME). */
100
- function isAgentMark(k) {
101
- return ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "AI_AGENT", "OPENCODE", "OPENCODE_PID"].includes(k) || (/^(CODEX|ANTIGRAVITY)_/.test(k) && !/_HOME$/.test(k));
102
- }
103
100
  export function openInNewTerminal(argv, cwd, env, platform = process.platform) {
104
101
  if (env.STROM_NO_OPEN === "1")
105
102
  return false;