strom-research 1.3.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.
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",
@@ -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";
@@ -42,7 +43,7 @@ the researcher; \`strom\` is your only way to read and change the research.
42
43
  already short, listings take \`--limit\` and \`--page\`, and piped commands may
43
44
  be refused by your permissions.
44
45
  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
46
+ OpenCode, Grok — the user's choice). \`strom\` shows who is working now; never take a task
46
47
  another one has started.
47
48
 
48
49
  ## Working with the user
@@ -130,76 +131,100 @@ export function permissionPath(abs) {
130
131
  return win ? `//${win[1].toLowerCase()}/${win[2]}` : `/${p}`;
131
132
  }
132
133
  /**
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).
134
+ * The tree's rules for Claude Code or Grok Build. Grok reads Claude Code's rules too, but an absolute
135
+ * path in Claude Code's words ("//Users/x") is plain text to it: its own file gets the path as it is.
136
+ * File rules are Edit(…) only: Claude Code checks every file-editing tool against them (Write(…) rules
137
+ * match nothing and are reported as a mistake); Grok's Edit covers Write as well.
136
138
  */
137
- export function claudeSettings(tree) {
139
+ function treeRules(tree, agent) {
138
140
  const settings = new Settings(tree.env, {});
139
141
  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})`];
142
+ const claude = agent === "claude";
143
+ const at = (abs) => (claude ? permissionPath(abs) : abs.replace(/\\/g, "/"));
144
+ const keys = at(configDir(tree.env));
145
+ // Connectors of this tree whose images come through the user's browser: browser tools (Claude in Chrome), for their sites only.
146
+ const sites = claude ? [...new Set(treeBrowserConnectors(tree, shared).flatMap((c) => c.manifest.hosts.map(chromeDomain)))] : [];
147
+ const downloads = at(settings.downloads());
148
+ // A rule for each of Claude Code's shells: Bash, and PowerShell (on Windows) — a Bash rule does not cover it. Grok has one.
149
+ const shell = (cmd) => (claude ? [`Bash(${cmd})`, `PowerShell(${cmd})`] : [`Bash(${cmd})`]);
148
150
  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
- },
151
+ allow: [
152
+ ...shell("strom:*"),
153
+ "Read(inputs/**)",
154
+ "Read(output/**)",
155
+ "Read(notes/**)",
156
+ "Read(.strom/views/**)",
157
+ // Files the user drops for the research; scans are seen through views only (strom media view).
158
+ ...(shared ? [`Read(${at(path.join(shared, "inbox"))}/**)`] : []),
159
+ "Edit(notes/**)",
160
+ // A connector it builds for an archive the research needs (strom connector new); strom runs it.
161
+ ...(shared ? ["Read", "Edit"].map((t) => `${t}(${at(path.join(shared, "plugins", "connectors"))}/**)`) : []),
162
+ "WebSearch",
163
+ "WebFetch",
164
+ ...(sites.length ? [...CHROME_ALLOW, ...sites] : []),
165
+ ],
166
+ deny: [
167
+ "Read(data/**)",
168
+ // The media store: images are looked at through views, so strom knows what was seen.
169
+ ...(shared ? [`Read(${at(path.join(shared, "media"))}/**)`] : []),
170
+ "Edit(data/**)",
171
+ "Edit(strom.json)",
172
+ "Edit(.git/**)",
173
+ ...shell("git:*"),
174
+ // A password is typed by the user in their own terminal, and so is installing a plugin; the seal is strom's.
175
+ // (Consents the agent may ask for — strom allow …: strom asks the person in a window.)
176
+ ...shell("strom login:*"),
177
+ ...shell("strom connector add:*"),
178
+ ...shell("strom connector remove:*"),
179
+ ...shell("strom seal:*"),
180
+ // The limiter's memory (pace, refusals).
181
+ ...(shared ? [`Edit(${at(path.join(shared, "net"))}/**)`] : []),
182
+ // A session ends with its turn: nothing wakes it up later (a live run waited for a wake-up that never came).
183
+ ...(claude ? ["ScheduleWakeup", "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
+ ...(claude ? 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
+ };
194
+ }
195
+ /** Permissions for Claude Code in this tree (paths of this computer). */
196
+ export function claudeSettings(tree) {
197
+ const lead = new Settings(tree.env, {}).models("claude", tree.config).lead;
198
+ return {
199
+ permissions: treeRules(tree, "claude"),
194
200
  // The user's model: the desktop app cannot be given one when it opens (the CLI is, with --model).
195
201
  ...(lead ? { model: lead } : {}),
196
202
  };
197
203
  }
204
+ /**
205
+ * Grok Build's rules for this tree (.grok/config.toml, read once the folder is trusted — strom starts it
206
+ * so). It reads .claude/settings.json as well; this file says the same with paths it understands.
207
+ */
208
+ export function grokConfig(tree) {
209
+ const { allow, deny } = treeRules(tree, "grok");
210
+ // A TOML basic string is written like a JSON one.
211
+ const list = (xs) => `[\n${xs.map((x) => ` ${JSON.stringify(x)},`).join("\n")}\n]`;
212
+ 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`;
213
+ }
214
+ /** Grok Build reads CLAUDE.md too (for Claude Code's projects): what in it is not for Grok. */
215
+ const GROK_RULES = `# Grok Build in this family tree
216
+
217
+ CLAUDE.md in this folder is written for Claude Code: its way of delegating (the
218
+ Agent tool, the models opus, sonnet and haiku) is not yours. Follow AGENTS.md,
219
+ and read scans yourself as it says under "Reading scans".
220
+ `;
198
221
  /** Denied as well when the user lets the agent do everything else (agent.permissions full). */
199
222
  export const BYPASS_DENY = [
200
223
  // its own permissions and instructions
201
224
  "Edit(.claude/**)",
202
225
  ...["AGENTS.md", "CLAUDE.md"].map((f) => `Edit(${f})`),
226
+ // (Grok Build's own rules and instructions)
227
+ "Edit(.grok/**)",
203
228
  // downloads round strom's limiter
204
229
  "Bash(curl:*)",
205
230
  "Bash(wget:*)",
@@ -218,47 +243,63 @@ export function opencodeConfig(tree) {
218
243
  const abs = (p) => p.replace(/\\/g, "/");
219
244
  const full = settings.agentPermissions() === "full";
220
245
  const users = ["strom login *", "strom seal *", "strom connector add *", "strom connector remove *"];
246
+ // The user's model ("provider/model"): its conversation takes no --model, it reads it here.
247
+ const lead = settings.models("opencode", tree.config).lead;
248
+ const permission = {
249
+ bash: {
250
+ "*": "ask",
251
+ "strom *": "allow",
252
+ ...Object.fromEntries(users.map((c) => [c, "deny"])),
253
+ "git *": "deny",
254
+ ...(full ? { "curl *": "deny", "wget *": "deny" } : {}),
255
+ },
256
+ read: { "*": "allow", "data/*": "deny", ".git/*": "deny" },
257
+ edit: {
258
+ "*": "ask",
259
+ "notes/*": "allow",
260
+ "data/*": "deny",
261
+ ".git/*": "deny",
262
+ "strom.json": "deny",
263
+ // its own rules and instructions
264
+ "opencode.json": "deny",
265
+ ...Object.fromEntries(["AGENTS.md", "CLAUDE.md", ".claude/*"].map((f) => [f, "deny"])),
266
+ },
267
+ external_directory: {
268
+ "*": "ask",
269
+ ...(shared
270
+ ? {
271
+ [`${abs(path.join(shared, "inbox"))}/*`]: "allow",
272
+ [`${abs(path.join(shared, "plugins", "connectors"))}/*`]: "allow",
273
+ // images are looked at through views, so strom knows what was seen; the limiter's memory is strom's
274
+ [`${abs(path.join(shared, "media"))}/*`]: "deny",
275
+ [`${abs(path.join(shared, "net"))}/*`]: "deny",
276
+ }
277
+ : {}),
278
+ // the seal keys, and what the browser downloads (strom's to take over)
279
+ [`${abs(configDir(tree.env))}/*`]: "deny",
280
+ [`${abs(settings.downloads())}/*`]: "deny",
281
+ },
282
+ webfetch: "allow",
283
+ websearch: "allow",
284
+ };
285
+ // What would ask is refused when nobody watches: OpenCode 2 ends a whole headless run at the first question
286
+ // it cannot ask (found live), where a refusal only fails that call.
287
+ const refused = (v) => v === "ask" ? "deny" : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, refused(x)])) : v;
221
288
  return {
222
289
  $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",
290
+ ...(lead ? { model: lead } : {}),
291
+ permission,
292
+ agent: {
293
+ [OPENCODE_RUN_AGENT]: {
294
+ mode: "primary",
295
+ description: "strom run: the agent working alone, nobody to ask — what the rules do not allow is refused",
296
+ permission: refused(permission),
256
297
  },
257
- webfetch: "allow",
258
- websearch: "allow",
259
298
  },
260
299
  };
261
300
  }
301
+ /** OpenCode's agent for a run nobody watches under ask and auto (opencode run --agent). */
302
+ export const OPENCODE_RUN_AGENT = "strom-run";
262
303
  /** Files strom wrote for Gemini CLI (gone: Google ended it for personal accounts) — taken away when they are strom's. */
263
304
  const OBSOLETE = [
264
305
  ["GEMINI.md", (t) => t.startsWith("@./AGENTS.md") && t.includes(MARKER) && t.slice(t.indexOf(MARKER) + MARKER.length).trim() === ""],
@@ -277,7 +318,7 @@ function withUserPart(file, generated) {
277
318
  }
278
319
  return generated;
279
320
  }
280
- export const AGENT_FILES = ["AGENTS.md", "CLAUDE.md", path.join(".claude", "settings.json"), "opencode.json"];
321
+ 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
322
  export function syncAgentFiles(tree) {
282
323
  const written = [];
283
324
  const write = (rel, content) => {
@@ -300,6 +341,8 @@ export function syncAgentFiles(tree) {
300
341
  write("CLAUDE.md", claudeMd(tree));
301
342
  write(path.join(".claude", "settings.json"), JSON.stringify(claudeSettings(tree), null, 2) + "\n");
302
343
  write("opencode.json", JSON.stringify(opencodeConfig(tree), null, 2) + "\n");
344
+ write(path.join(".grok", "config.toml"), grokConfig(tree));
345
+ write(path.join(".grok", "rules", "strom.md"), GROK_RULES);
303
346
  for (const [rel, ours] of OBSOLETE) {
304
347
  const file = path.join(tree.root, rel);
305
348
  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";
@@ -26,6 +28,8 @@ mentions strom, a family tree or the Strom app.
26
28
 
27
29
  1. Run \`strom\` first. It says where the research stands and what to do next
28
30
  (\`strom --json\` for the same as data; \`strom guide\` explains the work).
31
+ Run each strom command on its own — no pipes, no \`;\` or \`&&\` chains: you
32
+ may run strom without asking, but not what is chained to it.
29
33
  2. Not set up yet: ask the user where to keep the research (suggest
30
34
  ~/Documents/Strom) and which language they want, then run
31
35
  \`strom setup --home "<folder>" --lang <code> --yes\`, then
@@ -66,7 +70,46 @@ function allowRules(agent) {
66
70
  if (agent === "antigravity")
67
71
  return ["command(strom)"];
68
72
  const commands = ["strom", ...(installation().launchers ?? [])];
69
- return commands.flatMap((c) => [`Bash(${c}:*)`, `PowerShell(${c}:*)`]);
73
+ // Grok Build: one shell tool (its rules know no PowerShell)
74
+ return commands.flatMap((c) => (agent === "grok" ? [`Bash(${c}:*)`] : [`Bash(${c}:*)`, `PowerShell(${c}:*)`]));
75
+ }
76
+ /**
77
+ * Grok Build's config.toml, strom's lines marked: a [permission] table of strom's when there is none,
78
+ * else strom's allow list in the user's table — when it has no allow list of its own (strom does not
79
+ * rewrite the user's list: Grok then asks before it runs strom, as the user set it).
80
+ */
81
+ const TOML_BEGIN = "# strom: begin (strom agents install; removed by strom agents uninstall)";
82
+ const TOML_END = "# strom: end";
83
+ function grokWithout(text) {
84
+ const lines = text.split("\n");
85
+ const out = [];
86
+ let inside = false;
87
+ for (const l of lines) {
88
+ if (l.trim() === TOML_BEGIN)
89
+ inside = true;
90
+ else if (l.trim() === TOML_END)
91
+ inside = false;
92
+ else if (!inside)
93
+ out.push(l);
94
+ }
95
+ return out.join("\n");
96
+ }
97
+ function grokWith(text) {
98
+ const rest = grokWithout(text);
99
+ const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
100
+ const lines = rest.split("\n");
101
+ const header = lines.findIndex((l) => /^\s*\[permission\]\s*(#.*)?$/.test(l));
102
+ if (header < 0) {
103
+ const body = rest.replace(/\s+$/, "");
104
+ return `${body ? `${body}\n\n` : ""}${TOML_BEGIN}\n[permission]\n${allow}\n${TOML_END}\n`;
105
+ }
106
+ // the user's table: up to the next table
107
+ let end = lines.findIndex((l, i) => i > header && /^\s*\[/.test(l));
108
+ if (end < 0)
109
+ end = lines.length;
110
+ if (lines.slice(header + 1, end).some((l) => /^\s*allow\s*=/.test(l)))
111
+ return undefined;
112
+ return [...lines.slice(0, header + 1), TOML_BEGIN, allow, TOML_END, ...lines.slice(header + 1)].join("\n");
70
113
  }
71
114
  /** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
72
115
  function isStromRule(agent, rule) {
@@ -74,6 +117,10 @@ function isStromRule(agent, rule) {
74
117
  return rule === "command(strom)";
75
118
  return /^(?:Bash|PowerShell)\((?:.*[\\/])?strom(?:\.exe|\.cmd)?:\*\)$/.test(rule);
76
119
  }
120
+ /** Grok Build's folder (GROK_HOME, else ~/.grok). */
121
+ function grokDir(env) {
122
+ return env.GROK_HOME ?? path.join(userHome(env), ".grok");
123
+ }
77
124
  /** OpenCode: the rule that lets it run strom without asking. */
78
125
  const OPENCODE_STROM = "strom *";
79
126
  /** OpenCode's global config folder (it follows XDG on every system). */
@@ -101,6 +148,8 @@ export function globalTargets(env) {
101
148
  { agent: "antigravity", file: path.join(home, ".gemini", "antigravity-cli", "settings.json"), kind: "allow" },
102
149
  { agent: "opencode", file: path.join(opencodeDir(env), "strom.md"), kind: "own" },
103
150
  { agent: "opencode", file: path.join(opencodeDir(env), "opencode.json"), kind: "allow" },
151
+ { agent: "grok", file: path.join(grokDir(env), "skills", "strom", "SKILL.md"), kind: "own" },
152
+ { agent: "grok", file: path.join(grokDir(env), "config.toml"), kind: "allow" },
104
153
  ];
105
154
  }
106
155
  const SKILL = `---
@@ -209,7 +258,13 @@ function removeAllow(t, s) {
209
258
  export function installGlobal(t) {
210
259
  const cur = read(t.file);
211
260
  let next;
212
- if (t.kind === "allow") {
261
+ if (t.kind === "allow" && t.agent === "grok") {
262
+ const with_ = grokWith(cur ?? "");
263
+ if (with_ === undefined)
264
+ return false;
265
+ next = with_;
266
+ }
267
+ else if (t.kind === "allow") {
213
268
  const s = settingsOf(cur);
214
269
  if (!s || hasAllow(t, s))
215
270
  return false;
@@ -217,7 +272,7 @@ export function installGlobal(t) {
217
272
  next = JSON.stringify(s, null, 2) + "\n";
218
273
  }
219
274
  else if (t.kind === "own")
220
- next = t.agent === "claude" ? SKILL : GLOBAL_TEXT;
275
+ next = t.agent === "claude" || t.agent === "grok" ? SKILL : GLOBAL_TEXT;
221
276
  else {
222
277
  const rest = cur ? withoutBlock(cur).replace(/\s+$/, "") : "";
223
278
  next = `${rest ? `${rest}\n\n` : ""}${BEGIN}\n${GLOBAL_TEXT}${END}\n`;
@@ -233,6 +288,16 @@ export function uninstallGlobal(t) {
233
288
  const cur = read(t.file);
234
289
  if (cur === undefined)
235
290
  return false;
291
+ if (t.kind === "allow" && t.agent === "grok") {
292
+ if (!cur.includes(TOML_BEGIN))
293
+ return false;
294
+ const rest = grokWithout(cur);
295
+ if (rest.trim())
296
+ writeFileAtomic(t.file, rest.replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n"));
297
+ else
298
+ fs.rmSync(t.file);
299
+ return true;
300
+ }
236
301
  if (t.kind === "allow") {
237
302
  const s = settingsOf(cur);
238
303
  // Any of strom's rules, also those another installation of strom wrote.
@@ -244,8 +309,8 @@ export function uninstallGlobal(t) {
244
309
  return true;
245
310
  }
246
311
  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")
312
+ // A skill is a folder of its own; OpenCode's instructions one file among the user's.
313
+ if (t.agent === "claude" || t.agent === "grok")
249
314
  fs.rmSync(path.dirname(t.file), { recursive: true, force: true });
250
315
  else
251
316
  fs.rmSync(t.file, { force: true });
@@ -272,6 +337,8 @@ export function isInstalled(t) {
272
337
  const cur = read(t.file);
273
338
  if (cur === undefined)
274
339
  return false;
340
+ if (t.kind === "allow" && t.agent === "grok")
341
+ return cur.includes(TOML_BEGIN) && grokWith(cur) === cur;
275
342
  if (t.kind === "allow")
276
343
  return hasAllow(t, settingsOf(cur) ?? {});
277
344
  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,8 +20,8 @@ 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
26
  Read the images yourself, in batches of at most ten: open a batch, write down
27
27
  what it gave (strom search add … for what was not found, facts for what was),
@@ -85,6 +85,17 @@ ${DELEGATION_RULES}
85
85
  models: {},
86
86
  instructions: () => SELF_READING,
87
87
  },
88
+ // xAI's Grok Build: its subagents take only its own models (no cheaper reader worth the images): it reads itself.
89
+ grok: {
90
+ id: "grok",
91
+ name: "Grok Build",
92
+ command: "grok",
93
+ exit: "/quit",
94
+ url: "https://x.ai/cli",
95
+ delegation: "strom",
96
+ models: {},
97
+ instructions: () => SELF_READING,
98
+ },
88
99
  };
89
100
  export const DEFAULT_AGENT = "claude";
90
101
  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",
@@ -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.0";
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.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {