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
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.2.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/
@@ -84,6 +84,10 @@ export class Tree {
84
84
  undo = new Map();
85
85
  /** Ops written during this process (for the automatic commit message). */
86
86
  written = [];
87
+ /** Of `written`, how many were told to onCommit already. */
88
+ told = 0;
89
+ /** Told of each commit with the operations it saved (the CLI tells the user's hooks: core/hooks.ts). */
90
+ static onCommit;
87
91
  env;
88
92
  /** Bumped on every write; lets derived indexes know they are stale. */
89
93
  version = 0;
@@ -233,6 +237,7 @@ export class Tree {
233
237
  this.touchedOps.clear();
234
238
  this.counters = undefined;
235
239
  this.written.length = 0;
240
+ this.told = 0;
236
241
  this.version++;
237
242
  return restored;
238
243
  }
@@ -414,7 +419,19 @@ export class Tree {
414
419
  commit(message, pathspec = ["."]) {
415
420
  const key = this.requireKey();
416
421
  git.resetCache(this.root); // another process may have committed since this one last looked
417
- return git.commitAll(this.root, message, pathspec, (treeHash) => commitSeal(key, treeHash));
422
+ const hash = git.commitAll(this.root, message, pathspec, (treeHash) => commitSeal(key, treeHash));
423
+ if (hash) {
424
+ const ops = this.written.slice(this.told);
425
+ this.told = this.written.length;
426
+ try {
427
+ if (ops.length)
428
+ Tree.onCommit?.(this, ops, hash);
429
+ }
430
+ catch {
431
+ // whoever listens never undoes a commit
432
+ }
433
+ }
434
+ return hash;
418
435
  }
419
436
  /** Append a signed operation to the current log (inside withTreeLock). */
420
437
  appendOp(op) {
@@ -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);
@@ -41,6 +41,9 @@ export function claudeArgs(opts) {
41
41
  args.push("--model", opts.model);
42
42
  if (opts.chrome !== undefined)
43
43
  args.push(opts.chrome ? "--chrome" : "--no-chrome");
44
+ // named always: its value is optional, and a bare flag would take the next argument
45
+ if (opts.remote)
46
+ args.push("--remote-control", opts.name ?? "Strom");
44
47
  args.push(...(opts.extraArgs ?? []));
45
48
  return args;
46
49
  }
@@ -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.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {