strom-research 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Strom Research
2
2
 
3
3
  Research your family's history with an AI agent. `strom` turns an AI agent —
4
- Claude Code, OpenAI Codex, Google Antigravity or OpenCode — into a careful
4
+ Claude Code, OpenAI Codex, Google Antigravity, OpenCode or xAI's Grok — into a careful
5
5
  genealogical researcher: the agent searches parish registers and archives, you
6
6
  decide, and only what a record proves goes into your family tree. The result is
7
7
  a GEDCOM file for the [Strom app](https://stromapp.info) or any other program.
@@ -171,7 +171,7 @@
171
171
  "ui.run.stop.time": "časový limit",
172
172
  "ui.run.stop.empty": "žádné otevřené úkoly",
173
173
  "ui.run.stop.problems": "doklady mají problém – strom check",
174
- "ui.run.stop.denied": "agent nesměl spouštět strom – má špatně nastavená oprávnění (.claude/settings.json rodokmenu); nic dalšího se nespustilo",
174
+ "ui.run.stop.denied": "agent nesměl spouštět strom – nepustila ho jeho oprávnění nebo sandbox; nic dalšího se nespustilo",
175
175
  "ui.run.stop.limit": "limit předplatného{resets}",
176
176
  "ui.run.stop.resets": " (obnoví se {at})",
177
177
  "ui.run.stop.auth": "agent není přihlášený – spusťte jednou {command} a přihlaste se",
@@ -209,6 +209,7 @@
209
209
  "ui.doc.yes": "ano",
210
210
  "ui.doc.default": "výchozí",
211
211
  "ui.doc.noagent": "není nainstalovaný žádný AI agent",
212
+ "ui.doc.agent.other": "žádný, kterého strom zná – strom teď ovládá jiný agent nebo program a výzkum dělá přes strom",
212
213
  "ui.doc.knows.yes": "ano, v jakékoli složce",
213
214
  "ui.doc.knows.no": "zatím ne: {agents}",
214
215
  "ui.doc.app.yes": "používáte ji",
@@ -171,7 +171,7 @@
171
171
  "ui.run.stop.time": "Zeitlimit",
172
172
  "ui.run.stop.empty": "keine offenen Aufgaben",
173
173
  "ui.run.stop.problems": "die Belege haben Probleme – strom check",
174
- "ui.run.stop.denied": "der Agent durfte strom nicht starten – seine Berechtigungen sind falsch (.claude/settings.json des Stammbaums); nichts weiter wurde gestartet",
174
+ "ui.run.stop.denied": "der Agent durfte strom nicht starten – seine Berechtigungen oder seine Sandbox ließen es nicht zu; nichts weiter wurde gestartet",
175
175
  "ui.run.stop.limit": "Limit des Abonnements{resets}",
176
176
  "ui.run.stop.resets": " (erneuert sich {at})",
177
177
  "ui.run.stop.auth": "der Agent ist nicht angemeldet – starten Sie einmal {command} und melden Sie sich an",
@@ -209,6 +209,7 @@
209
209
  "ui.doc.yes": "ja",
210
210
  "ui.doc.default": "Standard",
211
211
  "ui.doc.noagent": "kein KI-Agent installiert",
212
+ "ui.doc.agent.other": "keiner, den strom kennt – strom wird gerade von einem anderen Agenten oder Programm bedient, das die Forschung über strom macht",
212
213
  "ui.doc.knows.yes": "ja, in jedem Ordner",
213
214
  "ui.doc.knows.no": "noch nicht: {agents}",
214
215
  "ui.doc.app.yes": "Sie nutzen sie",
@@ -1,5 +1,12 @@
1
1
  # Method: reading scans
2
2
 
3
+ **Old handwriting is read by your strongest model** — you, or a subagent or
4
+ reader on that same model; never a faster, cheaper or "quick" pass. A misread
5
+ name sends the research to the wrong village. **A name or place the next
6
+ search depends on** (a surname, a birthplace, a parish) is read again on a
7
+ crop at full resolution before it is recorded; what stays unsure is recorded
8
+ as unsure and said so.
9
+
3
10
  An image you open stays in your context and is paid for again on every turn.
4
11
  Look at as few pixels as the question needs, and write down what you saw at once.
5
12
 
@@ -22,7 +29,7 @@ Look at as few pixels as the question needs, and write down what you saw at once
22
29
  It waits for its readers (often ten minutes or more): run it in the
23
30
  foreground and let it finish — your session ends with your turn, and
24
31
  whatever is left running in the background ends with it.
25
- (Your own subagents can read too, in batches of at most twelve — but what they
32
+ (Your own subagents can read too — on your own model, never a faster one — in batches of at most twelve — but what they
26
33
  report is lost with your session unless you write it down: record each
27
34
  report as it comes back, before you send the next readers.) Only the entries
28
35
  that will be cited need your own eyes, at full resolution.
@@ -61,7 +61,7 @@ Environment variables, besides the user's own:
61
61
  | `STROM_GATE` | the gate's name |
62
62
  | `STROM_TREE` | the family tree's folder |
63
63
  | `STROM_LANG` | the research language (cs, de, en…) |
64
- | `STROM_AGENT` | the agent: claude, codex, antigravity, opencode |
64
+ | `STROM_AGENT` | the agent: claude, codex, antigravity, opencode, grok |
65
65
  | `STROM_MODEL` | the main model, when one is set |
66
66
  | `STROM_SESSIONS` | sessions this run has had |
67
67
  | `STROM_COST_USD` | what this run has cost so far (as the agent reported it) |
@@ -1,9 +1,10 @@
1
1
  // Instruction and permission files for the agents that work in a tree.
2
- // AGENTS.md is the single source every agent reads (Codex, Antigravity and
3
- // OpenCode natively; CLAUDE.md imports it) and holds nothing agent-specific;
4
- // each agent's own file adds what only that agent needs. .claude/settings.json
5
- // (and OpenCode's opencode.json) allow `strom` and deny direct access to the
6
- // evidence — the first line of defence; the seal is the one that always holds.
2
+ // AGENTS.md is the single source every agent reads (Codex, Antigravity,
3
+ // OpenCode and Grok natively; CLAUDE.md imports it) and holds nothing
4
+ // agent-specific; each agent's own file adds what only that agent needs.
5
+ // .claude/settings.json (and OpenCode's opencode.json, Grok's .grok/config.toml)
6
+ // allow `strom` and deny direct access to the evidence — the first line of
7
+ // defence; the seal is the one that always holds.
7
8
  import fs from "node:fs";
8
9
  import path from "node:path";
9
10
  import { langName } from "../core/lang.js";
@@ -25,7 +26,10 @@ the researcher; \`strom\` is your only way to read and change the research.
25
26
  1. Start with \`strom\` — it says where things stand and what to do next.
26
27
  \`strom guide\` explains the work; \`strom help <command>\` any command.
27
28
  2. Work in sessions: \`strom session start\` gives you the brief for the next
28
- task; finish with \`strom session close --summary "…" --next "…"\`.
29
+ task; finish with \`strom session close --summary "…" --next "…"\`. The
30
+ next step is strom's queue (\`strom\` names it, \`strom plan\` lists it):
31
+ what the user wants instead becomes a task first — never search outside a
32
+ session and its task.
29
33
  3. Never create, edit or delete anything in \`data/\`, \`strom.json\` or \`.git\`,
30
34
  never read the files in \`data/\` (strom shows them better and shorter), and
31
35
  never run git yourself. strom detects changes and then refuses to write.
@@ -42,7 +46,7 @@ the researcher; \`strom\` is your only way to read and change the research.
42
46
  already short, listings take \`--limit\` and \`--page\`, and piped commands may
43
47
  be refused by your permissions.
44
48
  8. Other agents may work on this tree too (Claude Code, Codex, Antigravity,
45
- OpenCode — the user's choice). \`strom\` shows who is working now; never take a task
49
+ OpenCode, Grok — the user's choice). \`strom\` shows who is working now; never take a task
46
50
  another one has started.
47
51
 
48
52
  ## Working with the user
@@ -130,76 +134,100 @@ export function permissionPath(abs) {
130
134
  return win ? `//${win[1].toLowerCase()}/${win[2]}` : `/${p}`;
131
135
  }
132
136
  /**
133
- * Permissions for Claude Code in this tree (paths of this computer). File rules are
134
- * Edit(…) only: Claude Code checks every file-editing tool against them (Write(…)
135
- * rules match nothing and are reported as a mistake).
137
+ * The tree's rules for Claude Code or Grok Build. Grok reads Claude Code's rules too, but an absolute
138
+ * path in Claude Code's words ("//Users/x") is plain text to it: its own file gets the path as it is.
139
+ * File rules are Edit(…) only: Claude Code checks every file-editing tool against them (Write(…) rules
140
+ * match nothing and are reported as a mistake); Grok's Edit covers Write as well.
136
141
  */
137
- export function claudeSettings(tree) {
142
+ function treeRules(tree, agent) {
138
143
  const settings = new Settings(tree.env, {});
139
144
  const shared = settings.shared()?.value;
140
- const keys = permissionPath(configDir(tree.env));
141
- // Connectors of this tree whose images come through the user's browser: browser tools, for their sites only.
142
- const browser = treeBrowserConnectors(tree, shared);
143
- const sites = [...new Set(browser.flatMap((c) => c.manifest.hosts.map(chromeDomain)))];
144
- const downloads = permissionPath(settings.downloads());
145
- const lead = settings.models("claude", tree.config).lead;
146
- // A rule for each of Claude Code's shells: Bash, and PowerShell (on Windows) — a Bash rule does not cover it.
147
- const shell = (cmd) => [`Bash(${cmd})`, `PowerShell(${cmd})`];
145
+ const claude = agent === "claude";
146
+ const at = (abs) => (claude ? permissionPath(abs) : abs.replace(/\\/g, "/"));
147
+ const keys = at(configDir(tree.env));
148
+ // Connectors of this tree whose images come through the user's browser: browser tools (Claude in Chrome), for their sites only.
149
+ const sites = claude ? [...new Set(treeBrowserConnectors(tree, shared).flatMap((c) => c.manifest.hosts.map(chromeDomain)))] : [];
150
+ const downloads = at(settings.downloads());
151
+ // A rule for each of Claude Code's shells: Bash, and PowerShell (on Windows) — a Bash rule does not cover it. Grok has one.
152
+ const shell = (cmd) => (claude ? [`Bash(${cmd})`, `PowerShell(${cmd})`] : [`Bash(${cmd})`]);
148
153
  return {
149
- permissions: {
150
- allow: [
151
- ...shell("strom:*"),
152
- "Read(inputs/**)",
153
- "Read(output/**)",
154
- "Read(notes/**)",
155
- "Read(.strom/views/**)",
156
- // Files the user drops for the research; scans are seen through views only (strom media view).
157
- ...(shared ? [`Read(${permissionPath(path.join(shared, "inbox"))}/**)`] : []),
158
- "Edit(notes/**)",
159
- // A connector it builds for an archive the research needs (strom connector new); strom runs it.
160
- ...(shared ? ["Read", "Edit"].map((t) => `${t}(${permissionPath(path.join(shared, "plugins", "connectors"))}/**)`) : []),
161
- "WebSearch",
162
- "WebFetch",
163
- ...(sites.length ? [...CHROME_ALLOW, ...sites] : []),
164
- ],
165
- deny: [
166
- "Read(data/**)",
167
- // The media store: images are looked at through views, so strom knows what was seen.
168
- ...(shared ? [`Read(${permissionPath(path.join(shared, "media"))}/**)`] : []),
169
- "Edit(data/**)",
170
- "Edit(strom.json)",
171
- "Edit(.git/**)",
172
- ...shell("git:*"),
173
- // A password is typed by the user in their own terminal, and so is installing a plugin; the seal is strom's.
174
- // (Consents the agent may ask for — strom allow …: strom asks the person in a window.)
175
- ...shell("strom login:*"),
176
- ...shell("strom connector add:*"),
177
- ...shell("strom connector remove:*"),
178
- ...shell("strom seal:*"),
179
- // The limiter's memory (pace, refusals).
180
- ...(shared ? [`Edit(${permissionPath(path.join(shared, "net"))}/**)`] : []),
181
- // A session ends with its turn: nothing wakes it up later (a live run waited for a wake-up that never came).
182
- "ScheduleWakeup",
183
- "CronCreate",
184
- // The seal keys: an agent that could read them could forge the seal.
185
- `Read(${keys}/**)`,
186
- `Edit(${keys}/**)`,
187
- // What the browser downloads is strom's to take over; the rest of the folder is the user's.
188
- `Read(${downloads}/**)`,
189
- ...CHROME_DENY,
190
- // With full permissions only the deny rules count: what the allow list kept away is kept away here.
191
- ...(settings.agentPermissions() === "full" ? BYPASS_DENY : []),
192
- ],
193
- },
154
+ allow: [
155
+ ...shell("strom:*"),
156
+ "Read(inputs/**)",
157
+ "Read(output/**)",
158
+ "Read(notes/**)",
159
+ "Read(.strom/views/**)",
160
+ // Files the user drops for the research; scans are seen through views only (strom media view).
161
+ ...(shared ? [`Read(${at(path.join(shared, "inbox"))}/**)`] : []),
162
+ "Edit(notes/**)",
163
+ // A connector it builds for an archive the research needs (strom connector new); strom runs it.
164
+ ...(shared ? ["Read", "Edit"].map((t) => `${t}(${at(path.join(shared, "plugins", "connectors"))}/**)`) : []),
165
+ "WebSearch",
166
+ "WebFetch",
167
+ ...(sites.length ? [...CHROME_ALLOW, ...sites] : []),
168
+ ],
169
+ deny: [
170
+ "Read(data/**)",
171
+ // The media store: images are looked at through views, so strom knows what was seen.
172
+ ...(shared ? [`Read(${at(path.join(shared, "media"))}/**)`] : []),
173
+ "Edit(data/**)",
174
+ "Edit(strom.json)",
175
+ "Edit(.git/**)",
176
+ ...shell("git:*"),
177
+ // A password is typed by the user in their own terminal, and so is installing a plugin; the seal is strom's.
178
+ // (Consents the agent may ask for — strom allow …: strom asks the person in a window.)
179
+ ...shell("strom login:*"),
180
+ ...shell("strom connector add:*"),
181
+ ...shell("strom connector remove:*"),
182
+ ...shell("strom seal:*"),
183
+ // The limiter's memory (pace, refusals).
184
+ ...(shared ? [`Edit(${at(path.join(shared, "net"))}/**)`] : []),
185
+ // A session ends with its turn: nothing wakes it up later (a live run waited for a wake-up that never came).
186
+ ...(claude ? ["ScheduleWakeup", "CronCreate"] : []),
187
+ // The seal keys: an agent that could read them could forge the seal.
188
+ `Read(${keys}/**)`,
189
+ `Edit(${keys}/**)`,
190
+ // What the browser downloads is strom's to take over; the rest of the folder is the user's.
191
+ `Read(${downloads}/**)`,
192
+ ...(claude ? CHROME_DENY : []),
193
+ // With full permissions only the deny rules count: what the allow list kept away is kept away here.
194
+ ...(settings.agentPermissions() === "full" ? BYPASS_DENY : []),
195
+ ],
196
+ };
197
+ }
198
+ /** Permissions for Claude Code in this tree (paths of this computer). */
199
+ export function claudeSettings(tree) {
200
+ const lead = new Settings(tree.env, {}).models("claude", tree.config).lead;
201
+ return {
202
+ permissions: treeRules(tree, "claude"),
194
203
  // The user's model: the desktop app cannot be given one when it opens (the CLI is, with --model).
195
204
  ...(lead ? { model: lead } : {}),
196
205
  };
197
206
  }
207
+ /**
208
+ * Grok Build's rules for this tree (.grok/config.toml, read once the folder is trusted — strom starts it
209
+ * so). It reads .claude/settings.json as well; this file says the same with paths it understands.
210
+ */
211
+ export function grokConfig(tree) {
212
+ const { allow, deny } = treeRules(tree, "grok");
213
+ // A TOML basic string is written like a JSON one.
214
+ const list = (xs) => `[\n${xs.map((x) => ` ${JSON.stringify(x)},`).join("\n")}\n]`;
215
+ return `# strom: generated (strom agents sync) — Grok Build's rules for this family tree; changed by strom only\n[permission]\nallow = ${list(allow)}\ndeny = ${list(deny)}\n`;
216
+ }
217
+ /** Grok Build reads CLAUDE.md too (for Claude Code's projects): what in it is not for Grok. */
218
+ const GROK_RULES = `# Grok Build in this family tree
219
+
220
+ CLAUDE.md in this folder is written for Claude Code: its way of delegating (the
221
+ Agent tool, the models opus, sonnet and haiku) is not yours. Follow AGENTS.md,
222
+ and read scans yourself as it says under "Reading scans".
223
+ `;
198
224
  /** Denied as well when the user lets the agent do everything else (agent.permissions full). */
199
225
  export const BYPASS_DENY = [
200
226
  // its own permissions and instructions
201
227
  "Edit(.claude/**)",
202
228
  ...["AGENTS.md", "CLAUDE.md"].map((f) => `Edit(${f})`),
229
+ // (Grok Build's own rules and instructions)
230
+ "Edit(.grok/**)",
203
231
  // downloads round strom's limiter
204
232
  "Bash(curl:*)",
205
233
  "Bash(wget:*)",
@@ -218,47 +246,63 @@ export function opencodeConfig(tree) {
218
246
  const abs = (p) => p.replace(/\\/g, "/");
219
247
  const full = settings.agentPermissions() === "full";
220
248
  const users = ["strom login *", "strom seal *", "strom connector add *", "strom connector remove *"];
249
+ // The user's model ("provider/model"): its conversation takes no --model, it reads it here.
250
+ const lead = settings.models("opencode", tree.config).lead;
251
+ const permission = {
252
+ bash: {
253
+ "*": "ask",
254
+ "strom *": "allow",
255
+ ...Object.fromEntries(users.map((c) => [c, "deny"])),
256
+ "git *": "deny",
257
+ ...(full ? { "curl *": "deny", "wget *": "deny" } : {}),
258
+ },
259
+ read: { "*": "allow", "data/*": "deny", ".git/*": "deny" },
260
+ edit: {
261
+ "*": "ask",
262
+ "notes/*": "allow",
263
+ "data/*": "deny",
264
+ ".git/*": "deny",
265
+ "strom.json": "deny",
266
+ // its own rules and instructions
267
+ "opencode.json": "deny",
268
+ ...Object.fromEntries(["AGENTS.md", "CLAUDE.md", ".claude/*"].map((f) => [f, "deny"])),
269
+ },
270
+ external_directory: {
271
+ "*": "ask",
272
+ ...(shared
273
+ ? {
274
+ [`${abs(path.join(shared, "inbox"))}/*`]: "allow",
275
+ [`${abs(path.join(shared, "plugins", "connectors"))}/*`]: "allow",
276
+ // images are looked at through views, so strom knows what was seen; the limiter's memory is strom's
277
+ [`${abs(path.join(shared, "media"))}/*`]: "deny",
278
+ [`${abs(path.join(shared, "net"))}/*`]: "deny",
279
+ }
280
+ : {}),
281
+ // the seal keys, and what the browser downloads (strom's to take over)
282
+ [`${abs(configDir(tree.env))}/*`]: "deny",
283
+ [`${abs(settings.downloads())}/*`]: "deny",
284
+ },
285
+ webfetch: "allow",
286
+ websearch: "allow",
287
+ };
288
+ // What would ask is refused when nobody watches: OpenCode 2 ends a whole headless run at the first question
289
+ // it cannot ask (found live), where a refusal only fails that call.
290
+ const refused = (v) => v === "ask" ? "deny" : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, refused(x)])) : v;
221
291
  return {
222
292
  $schema: "https://opencode.ai/config.json",
223
- permission: {
224
- bash: {
225
- "*": "ask",
226
- "strom *": "allow",
227
- ...Object.fromEntries(users.map((c) => [c, "deny"])),
228
- "git *": "deny",
229
- ...(full ? { "curl *": "deny", "wget *": "deny" } : {}),
230
- },
231
- read: { "*": "allow", "data/*": "deny", ".git/*": "deny" },
232
- edit: {
233
- "*": "ask",
234
- "notes/*": "allow",
235
- "data/*": "deny",
236
- ".git/*": "deny",
237
- "strom.json": "deny",
238
- // its own rules and instructions
239
- "opencode.json": "deny",
240
- ...Object.fromEntries(["AGENTS.md", "CLAUDE.md", ".claude/*"].map((f) => [f, "deny"])),
241
- },
242
- external_directory: {
243
- "*": "ask",
244
- ...(shared
245
- ? {
246
- [`${abs(path.join(shared, "inbox"))}/*`]: "allow",
247
- [`${abs(path.join(shared, "plugins", "connectors"))}/*`]: "allow",
248
- // images are looked at through views, so strom knows what was seen; the limiter's memory is strom's
249
- [`${abs(path.join(shared, "media"))}/*`]: "deny",
250
- [`${abs(path.join(shared, "net"))}/*`]: "deny",
251
- }
252
- : {}),
253
- // the seal keys, and what the browser downloads (strom's to take over)
254
- [`${abs(configDir(tree.env))}/*`]: "deny",
255
- [`${abs(settings.downloads())}/*`]: "deny",
293
+ ...(lead ? { model: lead } : {}),
294
+ permission,
295
+ agent: {
296
+ [OPENCODE_RUN_AGENT]: {
297
+ mode: "primary",
298
+ description: "strom run: the agent working alone, nobody to ask — what the rules do not allow is refused",
299
+ permission: refused(permission),
256
300
  },
257
- webfetch: "allow",
258
- websearch: "allow",
259
301
  },
260
302
  };
261
303
  }
304
+ /** OpenCode's agent for a run nobody watches under ask and auto (opencode run --agent). */
305
+ export const OPENCODE_RUN_AGENT = "strom-run";
262
306
  /** Files strom wrote for Gemini CLI (gone: Google ended it for personal accounts) — taken away when they are strom's. */
263
307
  const OBSOLETE = [
264
308
  ["GEMINI.md", (t) => t.startsWith("@./AGENTS.md") && t.includes(MARKER) && t.slice(t.indexOf(MARKER) + MARKER.length).trim() === ""],
@@ -277,7 +321,7 @@ function withUserPart(file, generated) {
277
321
  }
278
322
  return generated;
279
323
  }
280
- export const AGENT_FILES = ["AGENTS.md", "CLAUDE.md", path.join(".claude", "settings.json"), "opencode.json"];
324
+ export const AGENT_FILES = ["AGENTS.md", "CLAUDE.md", path.join(".claude", "settings.json"), "opencode.json", path.join(".grok", "config.toml"), path.join(".grok", "rules", "strom.md")];
281
325
  export function syncAgentFiles(tree) {
282
326
  const written = [];
283
327
  const write = (rel, content) => {
@@ -300,6 +344,8 @@ export function syncAgentFiles(tree) {
300
344
  write("CLAUDE.md", claudeMd(tree));
301
345
  write(path.join(".claude", "settings.json"), JSON.stringify(claudeSettings(tree), null, 2) + "\n");
302
346
  write("opencode.json", JSON.stringify(opencodeConfig(tree), null, 2) + "\n");
347
+ write(path.join(".grok", "config.toml"), grokConfig(tree));
348
+ write(path.join(".grok", "rules", "strom.md"), GROK_RULES);
303
349
  for (const [rel, ours] of OBSOLETE) {
304
350
  const file = path.join(tree.root, rel);
305
351
  let text;
@@ -8,7 +8,9 @@
8
8
  // is the user's — a password, a consent — strom itself keeps for the user).
9
9
  // OpenCode gets a file of its own named in its global config (a global
10
10
  // AGENTS.md of strom's would hide the user's ~/.claude/CLAUDE.md, which
11
- // OpenCode reads when it has none) and `strom *` allowed there.
11
+ // OpenCode reads when it has none) and `strom *` allowed there. Grok Build
12
+ // gets a skill of its own, like Claude Code, and strom allowed in its
13
+ // config.toml (a marked block; the user's own text around it is kept).
12
14
  import fs from "node:fs";
13
15
  import path from "node:path";
14
16
  import { userHome } from "../core/paths.js";
@@ -24,8 +26,12 @@ registers, archives, evidence, a family tree the Strom app can import.
24
26
  Use it whenever the user wants to research their ancestors or family, or
25
27
  mentions strom, a family tree or the Strom app.
26
28
 
27
- 1. Run \`strom\` first. It says where the research stands and what to do next
28
- (\`strom --json\` for the same as data; \`strom guide\` explains the work).
29
+ 1. Run \`strom\` first, and before each step: it says where the research
30
+ stands and what to do next (\`strom --json\` for the same as data). Read
31
+ \`strom guide\` before the first step — the rules and the way to work —
32
+ and work only in strom's sessions on its tasks.
33
+ Run each strom command on its own — no pipes, no \`;\` or \`&&\` chains: you
34
+ may run strom without asking, but not what is chained to it.
29
35
  2. Not set up yet: ask the user where to keep the research (suggest
30
36
  ~/Documents/Strom) and which language they want, then run
31
37
  \`strom setup --home "<folder>" --lang <code> --yes\`, then
@@ -43,10 +49,13 @@ mentions strom, a family tree or the Strom app.
43
49
  6. When strom needs the user's consent it opens a window on their screen —
44
50
  tell them to answer it; you cannot. Exit code 4 means the user must run
45
51
  the given command themselves.
46
- 7. Scans come through strom. An archive the research needs has no
52
+ 7. Old handwriting is read by your strongest model — never a faster or
53
+ cheaper model, subagent or pass; a name or place the next search depends
54
+ on is read again on an enlarged crop before it is recorded.
55
+ 8. Scans come through strom. An archive the research needs has no
47
56
  downloader (connector) yet: build one yourself, unasked
48
57
  (\`strom connector new\`), and tell the user in a sentence.
49
- 8. The Strom app (https://stromapp.info) is strom's companion: a free family
58
+ 9. The Strom app (https://stromapp.info) is strom's companion: a free family
50
59
  tree app, no account, the data stay on the user's computer. The research's
51
60
  result (\`output/tree-strom.ged\`) opens in it. When the user wants to see
52
61
  the tree (or \`strom\` says to offer it — once), suggest it gently — best installed as an app from the browser,
@@ -66,7 +75,46 @@ function allowRules(agent) {
66
75
  if (agent === "antigravity")
67
76
  return ["command(strom)"];
68
77
  const commands = ["strom", ...(installation().launchers ?? [])];
69
- return commands.flatMap((c) => [`Bash(${c}:*)`, `PowerShell(${c}:*)`]);
78
+ // Grok Build: one shell tool (its rules know no PowerShell)
79
+ return commands.flatMap((c) => (agent === "grok" ? [`Bash(${c}:*)`] : [`Bash(${c}:*)`, `PowerShell(${c}:*)`]));
80
+ }
81
+ /**
82
+ * Grok Build's config.toml, strom's lines marked: a [permission] table of strom's when there is none,
83
+ * else strom's allow list in the user's table — when it has no allow list of its own (strom does not
84
+ * rewrite the user's list: Grok then asks before it runs strom, as the user set it).
85
+ */
86
+ const TOML_BEGIN = "# strom: begin (strom agents install; removed by strom agents uninstall)";
87
+ const TOML_END = "# strom: end";
88
+ function grokWithout(text) {
89
+ const lines = text.split("\n");
90
+ const out = [];
91
+ let inside = false;
92
+ for (const l of lines) {
93
+ if (l.trim() === TOML_BEGIN)
94
+ inside = true;
95
+ else if (l.trim() === TOML_END)
96
+ inside = false;
97
+ else if (!inside)
98
+ out.push(l);
99
+ }
100
+ return out.join("\n");
101
+ }
102
+ function grokWith(text) {
103
+ const rest = grokWithout(text);
104
+ const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
105
+ const lines = rest.split("\n");
106
+ const header = lines.findIndex((l) => /^\s*\[permission\]\s*(#.*)?$/.test(l));
107
+ if (header < 0) {
108
+ const body = rest.replace(/\s+$/, "");
109
+ return `${body ? `${body}\n\n` : ""}${TOML_BEGIN}\n[permission]\n${allow}\n${TOML_END}\n`;
110
+ }
111
+ // the user's table: up to the next table
112
+ let end = lines.findIndex((l, i) => i > header && /^\s*\[/.test(l));
113
+ if (end < 0)
114
+ end = lines.length;
115
+ if (lines.slice(header + 1, end).some((l) => /^\s*allow\s*=/.test(l)))
116
+ return undefined;
117
+ return [...lines.slice(0, header + 1), TOML_BEGIN, allow, TOML_END, ...lines.slice(header + 1)].join("\n");
70
118
  }
71
119
  /** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
72
120
  function isStromRule(agent, rule) {
@@ -74,6 +122,10 @@ function isStromRule(agent, rule) {
74
122
  return rule === "command(strom)";
75
123
  return /^(?:Bash|PowerShell)\((?:.*[\\/])?strom(?:\.exe|\.cmd)?:\*\)$/.test(rule);
76
124
  }
125
+ /** Grok Build's folder (GROK_HOME, else ~/.grok). */
126
+ function grokDir(env) {
127
+ return env.GROK_HOME ?? path.join(userHome(env), ".grok");
128
+ }
77
129
  /** OpenCode: the rule that lets it run strom without asking. */
78
130
  const OPENCODE_STROM = "strom *";
79
131
  /** OpenCode's global config folder (it follows XDG on every system). */
@@ -101,6 +153,8 @@ export function globalTargets(env) {
101
153
  { agent: "antigravity", file: path.join(home, ".gemini", "antigravity-cli", "settings.json"), kind: "allow" },
102
154
  { agent: "opencode", file: path.join(opencodeDir(env), "strom.md"), kind: "own" },
103
155
  { agent: "opencode", file: path.join(opencodeDir(env), "opencode.json"), kind: "allow" },
156
+ { agent: "grok", file: path.join(grokDir(env), "skills", "strom", "SKILL.md"), kind: "own" },
157
+ { agent: "grok", file: path.join(grokDir(env), "config.toml"), kind: "allow" },
104
158
  ];
105
159
  }
106
160
  const SKILL = `---
@@ -209,7 +263,13 @@ function removeAllow(t, s) {
209
263
  export function installGlobal(t) {
210
264
  const cur = read(t.file);
211
265
  let next;
212
- if (t.kind === "allow") {
266
+ if (t.kind === "allow" && t.agent === "grok") {
267
+ const with_ = grokWith(cur ?? "");
268
+ if (with_ === undefined)
269
+ return false;
270
+ next = with_;
271
+ }
272
+ else if (t.kind === "allow") {
213
273
  const s = settingsOf(cur);
214
274
  if (!s || hasAllow(t, s))
215
275
  return false;
@@ -217,7 +277,7 @@ export function installGlobal(t) {
217
277
  next = JSON.stringify(s, null, 2) + "\n";
218
278
  }
219
279
  else if (t.kind === "own")
220
- next = t.agent === "claude" ? SKILL : GLOBAL_TEXT;
280
+ next = t.agent === "claude" || t.agent === "grok" ? SKILL : GLOBAL_TEXT;
221
281
  else {
222
282
  const rest = cur ? withoutBlock(cur).replace(/\s+$/, "") : "";
223
283
  next = `${rest ? `${rest}\n\n` : ""}${BEGIN}\n${GLOBAL_TEXT}${END}\n`;
@@ -233,6 +293,16 @@ export function uninstallGlobal(t) {
233
293
  const cur = read(t.file);
234
294
  if (cur === undefined)
235
295
  return false;
296
+ if (t.kind === "allow" && t.agent === "grok") {
297
+ if (!cur.includes(TOML_BEGIN))
298
+ return false;
299
+ const rest = grokWithout(cur);
300
+ if (rest.trim())
301
+ writeFileAtomic(t.file, rest.replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n"));
302
+ else
303
+ fs.rmSync(t.file);
304
+ return true;
305
+ }
236
306
  if (t.kind === "allow") {
237
307
  const s = settingsOf(cur);
238
308
  // Any of strom's rules, also those another installation of strom wrote.
@@ -244,8 +314,8 @@ export function uninstallGlobal(t) {
244
314
  return true;
245
315
  }
246
316
  if (t.kind === "own") {
247
- // The skill is a folder of its own; OpenCode's instructions one file among the user's.
248
- if (t.agent === "claude")
317
+ // A skill is a folder of its own; OpenCode's instructions one file among the user's.
318
+ if (t.agent === "claude" || t.agent === "grok")
249
319
  fs.rmSync(path.dirname(t.file), { recursive: true, force: true });
250
320
  else
251
321
  fs.rmSync(t.file, { force: true });
@@ -272,6 +342,8 @@ export function isInstalled(t) {
272
342
  const cur = read(t.file);
273
343
  if (cur === undefined)
274
344
  return false;
345
+ if (t.kind === "allow" && t.agent === "grok")
346
+ return cur.includes(TOML_BEGIN) && grokWith(cur) === cur;
275
347
  if (t.kind === "allow")
276
348
  return hasAllow(t, settingsOf(cur) ?? {});
277
349
  return t.kind === "own" || cur.includes(BEGIN);
@@ -11,11 +11,15 @@
11
11
  // --dangerously-skip-permissions; `strom` is allowed in its settings (strom agents install).
12
12
  // OpenCode: ask and auto — the tree's rules (opencode.json), anything else it
13
13
  // asks (it has no review of its own); full — --auto, everything the rules do not deny.
14
+ // Grok Build: always --trust (the tree folder: else it reads neither AGENTS.md nor
15
+ // the tree's rules); ask — its own mode; auto — --permission-mode auto (its own
16
+ // review); full — --always-approve (only the deny rules count).
14
17
  //
15
18
  // Claude Code always gets the tree's allow and deny lists (--settings); Codex
16
- // works in its sandbox on the tree folder with the shared folder added and the
17
- // network on (strom fetches).
19
+ // works in its sandbox on the tree folder with its .git (strom commits) and the
20
+ // shared folder writable and the network on (strom fetches).
18
21
  import { claudeArgs } from "../runners/claude.js";
22
+ import { codexWritable } from "../runners/codex.js";
19
23
  /** The command line of a conversation with this agent. */
20
24
  export function conversationArgs(agent, o) {
21
25
  if (agent === "claude")
@@ -23,14 +27,17 @@ export function conversationArgs(agent, o) {
23
27
  if (agent === "codex") {
24
28
  const args = o.level === "full"
25
29
  ? ["--dangerously-bypass-approvals-and-sandbox"]
26
- : [...(o.level === "auto" ? ["--approve-for-me"] : ["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]), "-c", "sandbox_workspace_write.network_access=true", ...(o.shared ? ["--add-dir", o.shared] : [])];
30
+ : [...(o.level === "auto" ? ["--approve-for-me"] : ["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]), "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(o.root, o.shared)];
27
31
  return [...args, "--search", ...(o.model ? ["--model", o.model] : []), o.kickoff];
28
32
  }
29
33
  if (agent === "antigravity") {
30
34
  const mode = o.level === "full" ? ["--dangerously-skip-permissions"] : o.level === "auto" ? ["--mode", "accept-edits"] : [];
31
35
  return [...mode, ...(o.shared ? ["--add-dir", o.shared] : []), ...(o.model ? ["--model", o.model] : []), "--prompt-interactive", o.kickoff];
32
36
  }
37
+ // (its TUI takes no --model: the model is in the tree's opencode.json)
33
38
  if (agent === "opencode")
34
- return [...(o.level === "full" ? ["--auto"] : []), ...(o.model ? ["--model", o.model] : []), "--prompt", o.kickoff];
39
+ return [...(o.level === "full" ? ["--auto"] : []), "--prompt", o.kickoff];
40
+ if (agent === "grok")
41
+ return ["--trust", ...(o.level === "full" ? ["--always-approve"] : o.level === "auto" ? ["--permission-mode", "auto"] : []), ...(o.model ? ["--model", o.model] : []), o.kickoff];
35
42
  throw new Error(`no conversation launcher for agent "${agent}"`);
36
43
  }
@@ -20,13 +20,17 @@ const DELEGATION_RULES = `- Browsing a book, an index or a range of images ("is
20
20
  - Delegates never write to the research. You record their findings through
21
21
  strom; a negative result of a delegate is recorded as a search "by reader".
22
22
  - Only entries that will get a citation need your own eyes.`;
23
- /** For agents that cannot pick a model for a subagent (Codex, Antigravity, OpenCode): read yourself, in small batches. */
24
- export const SELF_READING = `## Reading scans (Codex, Antigravity, OpenCode)
23
+ /** For agents that cannot pick a model for a subagent (Codex, Antigravity, OpenCode, Grok): read yourself, in small batches. */
24
+ export const SELF_READING = `## Reading scans (Codex, Antigravity, OpenCode, Grok)
25
25
 
26
- Read the images yourself, in batches of at most ten: open a batch, write down
27
- what it gave (strom search add … for what was not found, facts for what was),
28
- then open the next. Images stay in your context and are paid for on every turn,
29
- so never keep more than one batch open.
26
+ Read the images yourself, with your strongest model — never hand old
27
+ handwriting to a faster or cheaper model, subagent or pass. Read them
28
+ in batches of at most ten: open a batch, write down what it gave (strom
29
+ search add … for what was not found, facts for what was), then open the
30
+ next. Images stay in your context and are paid for on every turn, so never
31
+ keep more than one batch open. A name or place the next search depends on:
32
+ read it again on a crop at full resolution (\`strom media view … --crop\`)
33
+ before you record it.
30
34
  `;
31
35
  export const PROFILES = {
32
36
  claude: {
@@ -85,6 +89,17 @@ ${DELEGATION_RULES}
85
89
  models: {},
86
90
  instructions: () => SELF_READING,
87
91
  },
92
+ // xAI's Grok Build: its subagents take only its own models (no cheaper reader worth the images): it reads itself.
93
+ grok: {
94
+ id: "grok",
95
+ name: "Grok Build",
96
+ command: "grok",
97
+ exit: "/quit",
98
+ url: "https://x.ai/cli",
99
+ delegation: "strom",
100
+ models: {},
101
+ instructions: () => SELF_READING,
102
+ },
88
103
  };
89
104
  export const DEFAULT_AGENT = "claude";
90
105
  export function profile(id) {
@@ -22,7 +22,7 @@ export const GLOBAL_OPTIONS = [
22
22
  { name: "home", type: "string", value: "<dir>", description: "Strom home folder (default from config)" },
23
23
  { name: "shared", type: "string", value: "<dir>", description: "shared data folder (media, catalog, tools)" },
24
24
  { name: "trees", type: "string", value: "<dir>", description: "folder holding the trees" },
25
- { name: "agent", type: "string", value: "<agent>", description: "AI agent: claude, codex, antigravity, opencode (default from config)" },
25
+ { name: "agent", type: "string", value: "<agent>", description: "AI agent: claude, codex, antigravity, opencode, grok (default from config)" },
26
26
  { name: "yes", type: "boolean", description: "accept suggested defaults, never ask" },
27
27
  { name: "debug", type: "boolean", description: "show technical details on errors" },
28
28
  { name: "version", type: "boolean", description: "print the version of strom" },
package/dist/cli/ui.js CHANGED
@@ -154,7 +154,7 @@ export const UI = {
154
154
  "ui.run.stop.time": "time limit",
155
155
  "ui.run.stop.empty": "no open tasks",
156
156
  "ui.run.stop.problems": "the evidence has problems — strom check",
157
- "ui.run.stop.denied": "the agent was not allowed to run strom — its permissions are wrong (.claude/settings.json of the tree); nothing more was started",
157
+ "ui.run.stop.denied": "the agent was not allowed to run strom — its permissions or its sandbox kept it away; nothing more was started",
158
158
  "ui.run.stop.limit": "subscription limit{resets}",
159
159
  "ui.run.stop.resets": " (resets {at})",
160
160
  "ui.run.stop.auth": "the agent is not logged in — run {command} once and log in",
@@ -187,6 +187,7 @@ export const UI = {
187
187
  "ui.doc.yes": "yes",
188
188
  "ui.doc.default": "default",
189
189
  "ui.doc.noagent": "no AI agent installed",
190
+ "ui.doc.agent.other": "none strom knows is installed — strom is run by another agent or program now, which does the research through strom",
190
191
  "ui.doc.knows.yes": "yes, in any folder",
191
192
  "ui.doc.knows.no": "not yet: {agents}",
192
193
  "ui.doc.app.yes": "you use it",
@@ -32,6 +32,12 @@ HARD RULES
32
32
  the research needs has none yet: build one first (strom connector new,
33
33
  its DISCOVERY.md) and tell the user in a sentence. The user saves images
34
34
  by hand only where the archive does not allow automation.
35
+ 8. Old handwriting is read by your strongest model — you, or a subagent on
36
+ that same model; never a faster, cheaper or "quick" pass. A name or place
37
+ the next search depends on is read again on a crop at full resolution
38
+ (strom media view … --crop) before it is recorded; unsure stays unsure.
39
+ 9. A find is told to the user only once it is recorded (the source with its
40
+ transcript, the facts citing it), and told from strom: strom person card.
35
41
  ${language}
36
42
 
37
43
  GETTING STARTED (once)
@@ -42,14 +48,20 @@ GETTING STARTED (once)
42
48
  strom intake --text "<what the user told you, in their words>"
43
49
  strom intake <file or folder> documents, photos, a GEDCOM or Strom tree
44
50
 
45
- EVERY SESSION
46
- strom session start the brief: task, what is known, method
47
- … work; record every finding at once …
51
+ THE WORK, STEP BY STEP (every agent — also one strom did not start: a
52
+ conversation opened anywhere, a bot on its own server)
53
+ strom where things stand and the next step — run it before each step
54
+ strom session start the next task of the queue (or: strom session start T0003, the one
55
+ the user chose): its brief — the task, what is known, the method
56
+ … work on that task only; record every finding at once …
48
57
  strom task done T0001 --result "…" a complete negative search is a result
49
- strom session close --summary "…" --next "…" (not finished: --continue)
50
- in a conversation, the next task is best begun in a fresh context (Claude Code: /clear) —
51
- suggest it to the user; many tasks waiting: the agent working on its own (strom run, the
52
- user starts it from strom's menu), every task in a fresh session
58
+ strom session close --summary "…" --next "…" (not finished: --continue) — it proposes the next tasks
59
+ then tell the user what was found (strom person card …) and what comes next (strom plan), and ask
60
+ before the next session. The next step is strom's queue: what the user wants instead becomes a
61
+ task first (strom task add …, or strom research new …) — never search outside a session and its task.
62
+ In a conversation the next task is best begun in a fresh context (Claude Code: /clear) — suggest
63
+ it to the user; many tasks waiting: the agent working on its own (strom run, the user starts it
64
+ from strom's menu), every task in a fresh session
53
65
 
54
66
  RECORDING
55
67
  A record (register entry, certificate) — the source first, then its facts:
@@ -20,6 +20,7 @@ import { currentSession, openSessions } from "../core/session.js";
20
20
  import { readByOtherModels } from "../core/review.js";
21
21
  import { liveHolder } from "../core/lock.js";
22
22
  import { isAgent } from "../core/which.js";
23
+ import { agentsHere } from "../core/apps.js";
23
24
  import { taskQueue, waitingForUser, waitingLines } from "./tasks.js";
24
25
  import { liveWorkers, runAlive } from "../core/workers.js";
25
26
  import { label } from "../core/people.js";
@@ -114,10 +115,13 @@ function orientation(ctx) {
114
115
  const open = currentSession(tree, ctx.env) ?? sessions.find((s) => !busy(s) && !(s.worker && s.worker !== ctx.env.STROM_WORKER && !human));
115
116
  const queued = taskQueue(tree).length;
116
117
  // An agent outside the tree that strom did not start (it set strom up from the web page, or was
117
- // opened in some folder): the research goes on in its own conversation, where the tree lives.
118
+ // opened in some folder): the research goes on in its own conversation, where the tree lives — when
119
+ // strom can start one here (the research's agent is on this computer; not so for a bot on its own
120
+ // server, e.g. Grok Bot: it goes on where it is).
118
121
  const rel = path.relative(tree.root, ctx.cwd);
119
122
  const inTree = rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
120
- const elsewhere = isAgent(ctx.env) && !ctx.env.STROM_WORKER && !ctx.env.STROM_SESSION && !inTree;
123
+ const chosen = ctx.settings.agent(tree.config).value;
124
+ const elsewhere = isAgent(ctx.env) && !ctx.env.STROM_WORKER && !ctx.env.STROM_SESSION && !inTree && agentsHere(ctx.env).some((a) => a.id === chosen);
121
125
  if (base.tree.errors > 0)
122
126
  base.next = { why: t("ui.why.check"), command: "strom check" };
123
127
  else if (elsewhere)
@@ -25,7 +25,7 @@ import { syncAgentFiles } from "../agents/files.js";
25
25
  import { setTreeSetting } from "./setup.js";
26
26
  import { RUNNERS } from "../runners/index.js";
27
27
  import { PROFILES } from "../agents/profiles.js";
28
- import { AGENTS, detectAgent, isAgent, which } from "../core/which.js";
28
+ import { AGENTS, detectAgent, isAgent, which, withoutAgentMarks } from "../core/which.js";
29
29
  import { askGate, ensureGatesDir, loadGate } from "../core/gate.js";
30
30
  import { keepAwake } from "../core/awake.js";
31
31
  import { deadlineOf, WRAP_UP_MS } from "../core/clock.js";
@@ -385,7 +385,7 @@ register({
385
385
  "the tree's permissions (anything else is denied), or with the terminal (--interactive). Stops at the\n" +
386
386
  "subscription limit, when the queue is empty, or at --until (a session already running finishes, within\n" +
387
387
  "--minutes). The agent knows when its session is stopped (the brief; strom's output counts down its last\n" +
388
- "10 minutes, a short session its last quarter); at --minutes it is stopped, and Claude Code, Codex and OpenCode\n" +
388
+ "10 minutes, a short session its last quarter); at --minutes it is stopped, and Claude Code, Codex, OpenCode and Grok\n" +
389
389
  "get 5 minutes more to write down what they found and close (an unfinished task goes back to the queue); a task\n" +
390
390
  "that comes back twice in a row with nothing recorded is parked. --task picks the tasks (one session each, in that order;\n" +
391
391
  "one done or held by another agent meanwhile is left out). --loop: session after session for as long as there\n" +
@@ -578,7 +578,7 @@ register({
578
578
  brief.text;
579
579
  const kickoff = `You are the researcher in strom session ${session.id}. Run \`strom brief\` and follow it; work only through strom; finish with \`strom session close\`.`;
580
580
  const env = {
581
- ...prependPath(runEnv, bin),
581
+ ...prependPath(withoutAgentMarks(runEnv), bin),
582
582
  STROM_SESSION: session.id,
583
583
  STROM_DEADLINE: new Date(deadline).toISOString(),
584
584
  STROM_MINUTES: String(minutes),
@@ -603,7 +603,9 @@ register({
603
603
  shared: ctx.settings.shared()?.value,
604
604
  ...(models.lead ? { model: models.lead } : {}),
605
605
  ...(opts.interactive ? { interactive: true } : {}),
606
- ...(runnerId === "claude" ? { chrome: browser.length > 0, permissions, remote: ctx.settings.agentRemote() } : {}),
606
+ // the level for every agent (each maps it to its own switches); the browser and Remote Control are Claude Code's
607
+ permissions,
608
+ ...(runnerId === "claude" ? { chrome: browser.length > 0, remote: ctx.settings.agentRemote() } : {}),
607
609
  ...(extra?.length ? { extraArgs: extra } : {}),
608
610
  onProgress: (l) => out(` · ${l}`),
609
611
  signal: stop.signal,
@@ -760,7 +762,7 @@ function costText(m) {
760
762
  return m?.costUsd !== undefined ? `$${m.costUsd.toFixed(2)}` : "";
761
763
  }
762
764
  /** How the user clears the agent's context, in the agent's own words. */
763
- const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new" };
765
+ const CLEAR = { claude: "/clear", codex: "/new", opencode: "/new", grok: "/new" };
764
766
  /** The hint after a session in a conversation: the next task in a fresh context (images stay in a context and are paid on every turn). */
765
767
  function freshContext(ctx, tree, s) {
766
768
  const since = Date.parse(s.started);
@@ -176,7 +176,11 @@ function diagnose(ctx) {
176
176
  const found = here.map((a) => a.id);
177
177
  const tree = ctx.hasTree() ? ctx.tree().config : undefined;
178
178
  const chosen = ctx.settings.agent(tree).value;
179
- if (!found.length)
179
+ // None strom knows, and no person at a terminal: another agent or program runs strom (a bot on its own
180
+ // server, found live: Grok Bot) — it does the research through strom, nothing is missing for it.
181
+ if (!found.length && (isAgent(ctx.env) || !ctx.io.tty))
182
+ add("agent", "ok", t("ui.doc.agent.other"));
183
+ else if (!found.length)
180
184
  add("agent", "fail", t("ui.doc.noagent"), `${FIX} (${researchUrl(lang, "agents")})`, "agent");
181
185
  else {
182
186
  const names = found.map((id) => `${PROFILES[id]?.name ?? id}${id === chosen ? ` (${t("ui.doc.default")})` : ""}`).join(", ");
@@ -13,7 +13,7 @@ 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";
@@ -63,7 +63,7 @@ register({
63
63
  "(agent.permissions: ask, auto, full), your model, and asks you what matters. Where you talk (agent.where): the\n" +
64
64
  "agent's desktop app when it is installed (Claude, ChatGPT/Codex, OpenCode — the tree folder and the first message\n" +
65
65
  "filled in; you confirm the folder and send it), else its CLI in this terminal (Claude Code, Codex, Antigravity,\n" +
66
- "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" +
67
67
  "stand and suggests what next.",
68
68
  options: [
69
69
  { name: "say", type: "string", value: "<text>", description: "your first message to the agent" },
@@ -124,6 +124,7 @@ register({
124
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 } };
125
125
  }
126
126
  const base = {
127
+ root: tree.root,
127
128
  kickoff,
128
129
  level,
129
130
  model: ctx.settings.models(agent, tree.config).lead,
@@ -154,7 +155,7 @@ register({
154
155
  // Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
155
156
  if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
156
157
  ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
157
- 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 } : {}) };
158
159
  delete env.STROM_HANDOVER;
159
160
  let code;
160
161
  try {
@@ -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()}`,
@@ -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
  }
@@ -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;
package/dist/core/tree.js CHANGED
@@ -23,7 +23,7 @@ import * as git from "./git.js";
23
23
  import { commitSeal, createKey, fingerprint, loadKey, sha256, signOp } from "./seal.js";
24
24
  import { stringifyCanonical } from "./json.js";
25
25
  export const TREE_FILE = "strom.json";
26
- export const VERSION = "1.3.0";
26
+ export const VERSION = "1.4.1";
27
27
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
28
28
  .strom/
29
29
  /media/
@@ -26,9 +26,13 @@ export const AGENTS = [
26
26
  { id: "codex", command: "codex", name: "OpenAI Codex CLI" },
27
27
  { id: "antigravity", command: "agy", name: "Antigravity CLI" },
28
28
  { id: "opencode", command: "opencode", name: "OpenCode" },
29
+ { id: "grok", command: "grok", name: "Grok Build" },
29
30
  ];
30
31
  /** Which AI agent's shell tool runs strom? (they set these variables) */
31
32
  export function detectAgent(env) {
33
+ // Grok first: it passes on the environment it was started from (a Claude Code terminal's too).
34
+ if (env.GROK_AGENT === "1")
35
+ return "grok";
32
36
  if (env.CLAUDECODE)
33
37
  return "claude";
34
38
  if (Object.keys(env).some((k) => k.startsWith("ANTIGRAVITY_")))
@@ -37,10 +41,24 @@ export function detectAgent(env) {
37
41
  return "codex";
38
42
  if (env.OPENCODE === "1" || env.OPENCODE_PID)
39
43
  return "opencode";
44
+ // Cursor's agents — also xAI's Grok Bot, on Cursor's platform (found live): not one strom starts, but an agent
45
+ if (env.CURSOR_AGENT)
46
+ return "cursor";
40
47
  if (env.AI_AGENT)
41
48
  return env.AI_AGENT.toLowerCase();
42
49
  return undefined;
43
50
  }
51
+ /** What says an agent runs strom (detectAgent) — not the user's own settings of an agent (…_HOME). */
52
+ export function isAgentMark(k) {
53
+ return ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "AI_AGENT", "OPENCODE", "OPENCODE_PID", "GROK_AGENT", "GROK_SESSION_ID", "CURSOR_AGENT"].includes(k) || (/^(CODEX|ANTIGRAVITY)_/.test(k) && !/_HOME$/.test(k));
54
+ }
55
+ /**
56
+ * The environment for an agent strom starts: without the marks of the agent strom itself was run
57
+ * from (a terminal of Grok's passes them on), so strom in the new agent's shell knows which one it is.
58
+ */
59
+ export function withoutAgentMarks(env) {
60
+ return Object.fromEntries(Object.entries(env).filter(([k]) => !isAgentMark(k)));
61
+ }
44
62
  /** Is an agent running strom — its shell tool, or an agent strom run started? */
45
63
  export function isAgent(env) {
46
64
  return Boolean(detectAgent(env) || env.STROM_SESSION);
@@ -1,15 +1,24 @@
1
1
  // OpenAI Codex CLI runner. Headless: `codex exec --json` in the tree folder
2
2
  // with the brief on stdin. The user's level decides the sandbox: ask and auto
3
- // work in Codex's sandbox on the tree folder (the shared folder added, the
4
- // network on — strom fetches), where what the sandbox refuses simply fails —
3
+ // work in Codex's sandbox on the tree folder (its .git and the shared folder
4
+ // writable too, the network on — strom fetches), where what the sandbox refuses simply fails —
5
5
  // nothing asks, as with Claude Code's dontAsk; full runs without the sandbox.
6
6
  // Interactive conversations are started by strom chat (agents/launch.ts).
7
+ import path from "node:path";
7
8
  import { runJsonLines } from "./jsonl.js";
9
+ /**
10
+ * What Codex's sandbox lets it write besides the tree folder: the tree's .git — its sandbox keeps a .git
11
+ * read-only even inside the folder it works in, and every change strom makes is a commit (found live:
12
+ * `strom session note` failed on .git/index.lock) — and the shared folder (inbox, plugins).
13
+ */
14
+ export function codexWritable(root, shared) {
15
+ return ["-c", `sandbox_workspace_write.writable_roots=${JSON.stringify([path.join(root, ".git"), ...(shared ? [shared] : [])])}`];
16
+ }
8
17
  /** Command-line arguments of a headless run (exported for tests). */
9
18
  export function codexArgs(opts) {
10
19
  const sandbox = opts.permissions === "full"
11
20
  ? ["--dangerously-bypass-approvals-and-sandbox"]
12
- : ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...(opts.shared ? ["--add-dir", opts.shared] : [])];
21
+ : ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(opts.cwd, opts.shared)];
13
22
  return ["exec", "--json", "--skip-git-repo-check", ...sandbox, ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), "-"];
14
23
  }
15
24
  /**
@@ -19,7 +28,7 @@ export function codexArgs(opts) {
19
28
  export function codexResumeArgs(id, opts) {
20
29
  const sandbox = opts.permissions === "full"
21
30
  ? ["--dangerously-bypass-approvals-and-sandbox"]
22
- : ["-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", ...(opts.shared ? ["-c", `sandbox_workspace_write.writable_roots=[${JSON.stringify(opts.shared)}]`] : [])];
31
+ : ["-c", 'sandbox_mode="workspace-write"', "-c", "sandbox_workspace_write.network_access=true", ...codexWritable(opts.cwd, opts.shared)];
23
32
  return ["exec", "resume", "--json", "--skip-git-repo-check", ...sandbox, ...(opts.model ? ["--model", opts.model] : []), id, "-"];
24
33
  }
25
34
  export const codexRunner = {
@@ -39,7 +48,7 @@ export const codexRunner = {
39
48
  }
40
49
  // The sandbox refused a command (a write outside the tree, the network off): counted like a denial.
41
50
  if (msg.type === "item.completed" && item?.type === "command_execution" && item.exit_code !== 0 && /operation not permitted|sandbox|read-only file system/i.test(item.aggregated_output ?? ""))
42
- heard.denied.push(`Bash: ${String(item.command ?? "").slice(0, 120)}`);
51
+ heard.denied.push(`Bash: ${String(item.command ?? "").replace(/^\S*sh -lc '(.*)'$/s, "$1").slice(0, 120)}`);
43
52
  if (msg.type === "turn.completed") {
44
53
  const u = (msg.usage ?? {});
45
54
  const m = heard.metrics;
@@ -0,0 +1,108 @@
1
+ // Grok Build runner (xAI's `grok`). Headless: `grok --trust --prompt-file <brief>
2
+ // --output-format streaming-json` in the tree folder — it reads no stdin, so the
3
+ // brief goes in a file beside the run's log. --trust: the tree folder's own files
4
+ // (AGENTS.md, .grok/config.toml with the tree's rules) load only in a trusted
5
+ // folder. The level: ask and auto — --permission-mode dontAsk (what the rules do
6
+ // not allow is refused; Grok still lets harmless commands of its own judgement
7
+ // through, so the deny rules are what holds); full — --always-approve (only the
8
+ // deny rules count). strom names the session (-s <uuid>) and resumes it by that
9
+ // id at the time limit (-r). Events are JSON lines: text, tool_call,
10
+ // tool_call_update, end (usage, cost) and error.
11
+ import { randomUUID } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { runJsonLines } from "./jsonl.js";
15
+ /** Command-line arguments of a headless run (exported for tests). */
16
+ export function grokArgs(opts, prompt, session) {
17
+ return [
18
+ "--trust",
19
+ ...prompt,
20
+ "--output-format",
21
+ "streaming-json",
22
+ ...(opts.permissions === "full" ? ["--always-approve"] : ["--permission-mode", "dontAsk"]),
23
+ ...session,
24
+ ...(opts.model ? ["--model", opts.model] : []),
25
+ "--no-auto-update",
26
+ ...(opts.extraArgs ?? []),
27
+ ];
28
+ }
29
+ /**
30
+ * The environment of a headless run: a command may take as long as the session and is never sent to the
31
+ * background when its time is up (a headless run ends with its turn, and what it left in the background with it).
32
+ */
33
+ export function grokEnv(env, timeoutMs) {
34
+ const secs = Math.max(Math.round((timeoutMs ?? 0) / 1000), 30 * 60);
35
+ return { ...env, GROK_CONFIG: JSON.stringify({ toolset: { bash: { timeout_secs: secs, max_timeout_secs: secs, auto_background_on_timeout: false } } }) };
36
+ }
37
+ /** What a tool call was, in one line: its command, else the tool and its file. */
38
+ function describe(e) {
39
+ const input = (e.rawInput ?? {});
40
+ if (typeof input.command === "string")
41
+ return `$ ${input.command.split("\n")[0].slice(0, 140)}`;
42
+ const file = input.target_file ?? input.file_path ?? input.path ?? input.target_directory;
43
+ return typeof file === "string" ? `${String(e.toolName ?? e.title ?? "tool")} ${file}` : String(e.toolName ?? e.title ?? "tool");
44
+ }
45
+ export const grokRunner = {
46
+ id: "grok",
47
+ command: "grok",
48
+ run(opts) {
49
+ const promptFile = opts.logFile.replace(/\.log$/, "") + ".prompt.md";
50
+ fs.mkdirSync(path.dirname(promptFile), { recursive: true });
51
+ fs.writeFileSync(promptFile, opts.prompt);
52
+ const id = randomUUID();
53
+ const env = grokEnv(opts.env, opts.timeoutMs);
54
+ // Its text comes in pieces; the last message (after the last tool call) is its answer.
55
+ let said = "";
56
+ const calls = new Map();
57
+ const resume = (sid, message) => ({ args: grokArgs(opts, ["-p", message], ["--resume", sid]), input: "" });
58
+ return runJsonLines("grok", grokArgs(opts, ["--prompt-file", promptFile], ["--session-id", id]), env, opts, resume, (msg, heard) => {
59
+ heard.sessionId ??= id;
60
+ const type = msg.type;
61
+ if (type === "text" && typeof msg.data === "string") {
62
+ said += msg.data;
63
+ heard.text = said.trim();
64
+ }
65
+ else if (type === "tool_call") {
66
+ if (said.trim())
67
+ opts.onProgress?.(said.trim().split("\n")[0].slice(0, 160));
68
+ said = "";
69
+ const what = describe(msg);
70
+ if (typeof msg.toolCallId === "string")
71
+ calls.set(msg.toolCallId, `${msg.toolName === "run_terminal_command" ? "Bash" : String(msg.toolName ?? "tool")}: ${what.replace(/^\$ /, "").slice(0, 120)}`);
72
+ opts.onProgress?.(what);
73
+ }
74
+ else if (type === "tool_call_update" && msg.status === "failed") {
75
+ // Refused by the rules ("Denied by permission policy") or by dontAsk ("User cancelled the execution").
76
+ const content = JSON.stringify(msg.content ?? "");
77
+ if (/denied by permission|permission policy|user cancelled|not allowed/i.test(content))
78
+ heard.denied.push(calls.get(String(msg.toolCallId)) ?? "tool");
79
+ }
80
+ else if (type === "end") {
81
+ if (typeof msg.sessionId === "string")
82
+ heard.sessionId = msg.sessionId;
83
+ const u = (msg.usage ?? {});
84
+ const m = heard.metrics;
85
+ // (a resumed session adds its own)
86
+ m.inputTokens = (m.inputTokens ?? 0) + (u.input_tokens ?? 0);
87
+ m.outputTokens = (m.outputTokens ?? 0) + (u.output_tokens ?? 0) + (u.reasoning_tokens ?? 0);
88
+ m.cacheReadTokens = (m.cacheReadTokens ?? 0) + (u.cache_read_input_tokens ?? 0);
89
+ if (typeof msg.num_turns === "number")
90
+ m.turns = (m.turns ?? 0) + msg.num_turns;
91
+ // A cost only when Grok knows it whole (a subscription often does not say it).
92
+ if (typeof msg.total_cost_usd === "number")
93
+ m.costUsd = (m.costUsd ?? 0) + msg.total_cost_usd;
94
+ else if (msg.cost_is_partial === true || msg.usage_is_incomplete === true)
95
+ m.costPartial = true;
96
+ const models = Object.keys((msg.modelUsage ?? {}));
97
+ if (models[0])
98
+ m.model = models[0];
99
+ if (msg.stopReason === "refusal")
100
+ heard.isError = true;
101
+ }
102
+ else if (type === "error") {
103
+ heard.isError = true;
104
+ heard.text = String(msg.message ?? "error");
105
+ }
106
+ });
107
+ },
108
+ };
@@ -2,12 +2,14 @@ import { claudeRunner } from "./claude.js";
2
2
  import { codexRunner } from "./codex.js";
3
3
  import { antigravityRunner } from "./antigravity.js";
4
4
  import { opencodeRunner } from "./opencode.js";
5
+ import { grokRunner } from "./grok.js";
5
6
  import { scriptRunner } from "./script.js";
6
7
  export const RUNNERS = {
7
8
  claude: claudeRunner,
8
9
  codex: codexRunner,
9
10
  antigravity: antigravityRunner,
10
11
  opencode: opencodeRunner,
12
+ grok: grokRunner,
11
13
  script: scriptRunner,
12
14
  };
13
15
  export const DEFAULT_RUNNER = "claude";
@@ -1,15 +1,18 @@
1
1
  // OpenCode runner. Headless: `opencode run --format json "<kickoff>"` in the tree
2
2
  // folder with the brief on stdin (it joins what is piped in with the message).
3
3
  // The tree's rules are its project config (opencode.json: strom allowed, the
4
- // evidence and git denied); what they would ask about is refused in a run
5
- // nobody watches — under ask and auto that is all; full adds --auto
4
+ // evidence and git denied); under ask and auto a run nobody watches is its
5
+ // agent "strom-run" there — the same rules, what would ask refused (OpenCode 2
6
+ // ends a headless run at the first question it cannot ask); full adds --auto
6
7
  // (everything the rules do not deny). Events are JSON lines: text, tool_use,
7
8
  // step_start, step_finish (tokens and cost of a step), error; each names its
8
9
  // session (sessionID), which `--session` resumes.
9
10
  import { runJsonLines } from "./jsonl.js";
11
+ import { OPENCODE_RUN_AGENT } from "../agents/files.js";
10
12
  /** Command-line arguments of a headless run (exported for tests). */
11
13
  export function opencodeArgs(opts) {
12
- return ["run", "--format", "json", ...(opts.permissions === "full" ? ["--auto"] : []), ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), opts.kickoff];
14
+ const level = opts.permissions === "full" ? ["--auto"] : ["--agent", OPENCODE_RUN_AGENT];
15
+ return ["run", "--format", "json", ...level, ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), opts.kickoff];
13
16
  }
14
17
  export const opencodeRunner = {
15
18
  id: "opencode",
@@ -28,8 +31,9 @@ export const opencodeRunner = {
28
31
  const input = state.input ?? {};
29
32
  const what = typeof input.command === "string" ? `$ ${input.command}` : typeof input.filePath === "string" ? `${String(part.tool)} ${input.filePath}` : String(part.tool ?? "tool");
30
33
  opts.onProgress?.(what.split("\n")[0].slice(0, 140));
34
+ // (its shell tool: "bash", "shell" from OpenCode 2 — named "Bash" like Claude Code's, so a refused strom stops the run)
31
35
  if (state.status === "error" && /reject|denied|not allowed|permission/i.test(state.error ?? ""))
32
- heard.denied.push(`${String(part.tool)}: ${what.slice(0, 120)}`);
36
+ heard.denied.push(typeof input.command === "string" ? `Bash: ${input.command.slice(0, 120)}` : `${String(part.tool)}: ${what.slice(0, 120)}`);
33
37
  }
34
38
  if (msg.type === "text" && typeof part.text === "string" && part.text.trim())
35
39
  heard.text = part.text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {