strom-research 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -1
  2. package/assets/lang/cs.json +164 -7
  3. package/assets/lang/de.json +164 -7
  4. package/assets/plugins/README.md +8 -1
  5. package/assets/plugins/gates/README.md +1 -1
  6. package/assets/plugins/hooks/README.md +91 -0
  7. package/dist/agents/files.js +142 -99
  8. package/dist/agents/global.js +83 -6
  9. package/dist/agents/launch.js +12 -5
  10. package/dist/agents/profiles.js +13 -2
  11. package/dist/cli/main.js +17 -1
  12. package/dist/cli/menu-parts.js +140 -0
  13. package/dist/cli/menu-research.js +194 -0
  14. package/dist/cli/menu-settings.js +273 -0
  15. package/dist/cli/menu-waiting.js +79 -0
  16. package/dist/cli/menu.js +91 -97
  17. package/dist/cli/registry.js +1 -1
  18. package/dist/cli/ui.js +162 -6
  19. package/dist/cli/wizard.js +48 -2
  20. package/dist/commands/browse.js +2 -2
  21. package/dist/commands/connectors.js +25 -2
  22. package/dist/commands/hooks.js +141 -0
  23. package/dist/commands/index.js +1 -0
  24. package/dist/commands/intake.js +2 -2
  25. package/dist/commands/meta.js +6 -2
  26. package/dist/commands/research.js +55 -16
  27. package/dist/commands/session.js +15 -7
  28. package/dist/commands/setup.js +51 -2
  29. package/dist/commands/start.js +9 -4
  30. package/dist/commands/tasks.js +41 -4
  31. package/dist/core/browser.js +50 -0
  32. package/dist/core/config.js +7 -1
  33. package/dist/core/hooks.js +210 -0
  34. package/dist/core/install.js +3 -2
  35. package/dist/core/media.js +21 -7
  36. package/dist/core/phrases.js +1 -0
  37. package/dist/core/relocate.js +111 -0
  38. package/dist/core/shortcut.js +1 -4
  39. package/dist/core/tree.js +19 -2
  40. package/dist/core/which.js +18 -0
  41. package/dist/runners/claude.js +3 -0
  42. package/dist/runners/codex.js +14 -5
  43. package/dist/runners/grok.js +108 -0
  44. package/dist/runners/index.js +2 -0
  45. package/dist/runners/opencode.js +8 -4
  46. package/package.json +1 -1
@@ -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,12 +117,26 @@ 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). */
80
127
  function opencodeDir(env) {
81
128
  return path.join(env.XDG_CONFIG_HOME ?? path.join(userHome(env), ".config"), "opencode");
82
129
  }
130
+ /** Claude Code starts its own conversations with Remote Control (its setting remoteControlAtStartup, the user's own). */
131
+ export function claudeRemoteAtStartup(env) {
132
+ try {
133
+ const file = path.join(env.CLAUDE_CONFIG_DIR ?? path.join(userHome(env), ".claude"), "settings.json");
134
+ return JSON.parse(fs.readFileSync(file, "utf8")).remoteControlAtStartup === true;
135
+ }
136
+ catch {
137
+ return false; // no settings, or not readable: not on
138
+ }
139
+ }
83
140
  export function globalTargets(env) {
84
141
  const home = userHome(env);
85
142
  return [
@@ -91,6 +148,8 @@ export function globalTargets(env) {
91
148
  { agent: "antigravity", file: path.join(home, ".gemini", "antigravity-cli", "settings.json"), kind: "allow" },
92
149
  { agent: "opencode", file: path.join(opencodeDir(env), "strom.md"), kind: "own" },
93
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" },
94
153
  ];
95
154
  }
96
155
  const SKILL = `---
@@ -199,7 +258,13 @@ function removeAllow(t, s) {
199
258
  export function installGlobal(t) {
200
259
  const cur = read(t.file);
201
260
  let next;
202
- 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") {
203
268
  const s = settingsOf(cur);
204
269
  if (!s || hasAllow(t, s))
205
270
  return false;
@@ -207,7 +272,7 @@ export function installGlobal(t) {
207
272
  next = JSON.stringify(s, null, 2) + "\n";
208
273
  }
209
274
  else if (t.kind === "own")
210
- next = t.agent === "claude" ? SKILL : GLOBAL_TEXT;
275
+ next = t.agent === "claude" || t.agent === "grok" ? SKILL : GLOBAL_TEXT;
211
276
  else {
212
277
  const rest = cur ? withoutBlock(cur).replace(/\s+$/, "") : "";
213
278
  next = `${rest ? `${rest}\n\n` : ""}${BEGIN}\n${GLOBAL_TEXT}${END}\n`;
@@ -223,6 +288,16 @@ export function uninstallGlobal(t) {
223
288
  const cur = read(t.file);
224
289
  if (cur === undefined)
225
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
+ }
226
301
  if (t.kind === "allow") {
227
302
  const s = settingsOf(cur);
228
303
  // Any of strom's rules, also those another installation of strom wrote.
@@ -234,8 +309,8 @@ export function uninstallGlobal(t) {
234
309
  return true;
235
310
  }
236
311
  if (t.kind === "own") {
237
- // The skill is a folder of its own; OpenCode's instructions one file among the user's.
238
- 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")
239
314
  fs.rmSync(path.dirname(t.file), { recursive: true, force: true });
240
315
  else
241
316
  fs.rmSync(t.file, { force: true });
@@ -262,6 +337,8 @@ export function isInstalled(t) {
262
337
  const cur = read(t.file);
263
338
  if (cur === undefined)
264
339
  return false;
340
+ if (t.kind === "allow" && t.agent === "grok")
341
+ return cur.includes(TOML_BEGIN) && grokWith(cur) === cur;
265
342
  if (t.kind === "allow")
266
343
  return hasAllow(t, settingsOf(cur) ?? {});
267
344
  return t.kind === "own" || cur.includes(BEGIN);
@@ -11,26 +11,33 @@
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")
22
- return claudeArgs({ interactive: true, kickoff: o.kickoff, permissions: o.level, settingsFile: o.settingsFile, ...(o.name ? { name: o.name } : {}), ...(o.model ? { model: o.model } : {}), ...(o.chrome !== undefined ? { chrome: o.chrome } : {}) });
26
+ return claudeArgs({ interactive: true, kickoff: o.kickoff, permissions: o.level, settingsFile: o.settingsFile, ...(o.name ? { name: o.name } : {}), ...(o.model ? { model: o.model } : {}), ...(o.chrome !== undefined ? { chrome: o.chrome } : {}), ...(o.remote ? { remote: true } : {}) });
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) {
package/dist/cli/main.js CHANGED
@@ -6,7 +6,10 @@ import { groupHelp, helpFor } from "./help.js";
6
6
  import { autoCommit } from "./commit.js";
7
7
  import { assertIntact } from "../core/integrity.js";
8
8
  import { resetCache } from "../core/git.js";
9
- import { VERSION } from "../core/tree.js";
9
+ import { Tree, VERSION } from "../core/tree.js";
10
+ import { fireHooks, HOOK_INTERFACE } from "../core/hooks.js";
11
+ import { prependPath } from "../runners/runner.js";
12
+ import { shimDir } from "../commands/session.js";
10
13
  import { isAgent } from "../core/which.js";
11
14
  import { noticeStromApp } from "../core/stromapp.js";
12
15
  import { isNewer } from "../core/update.js";
@@ -116,6 +119,19 @@ export async function main(argv, io, env, cwd) {
116
119
  const ctx = Context.fromOptions({ env, cwd, io, json, values: v });
117
120
  ran = ctx;
118
121
  command = def.path.join(" ");
122
+ // What is saved into a research, the user's hooks are told of (in the background).
123
+ Tree.onCommit = (tree, ops, commit) => {
124
+ if (!ctx.settings.config.hooks?.length || env.STROM_HOOK)
125
+ return;
126
+ // this strom on the hook's PATH: it reads more of what was saved (strom person card P0012)
127
+ fireHooks(ctx.settings.shared()?.value, ctx.settings.config.hooks, prependPath(env, shimDir(tree)), {
128
+ interface: HOOK_INTERFACE,
129
+ tree: { name: tree.config.name, id: tree.config.id, root: tree.root, lang: tree.lang },
130
+ commit,
131
+ at: new Date().toISOString(),
132
+ events: ops.map((o) => ({ op: o.op, targets: o.targets, summary: o.summary, at: o.at, by: o.by, ...(o.reason ? { reason: o.reason } : {}) })),
133
+ });
134
+ };
119
135
  // Started by the Strom app: remembered quietly (it is where the results go).
120
136
  if (env.STROM_APP)
121
137
  noticeStromApp(ctx.settings, env);