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.
- package/README.md +4 -1
- package/assets/lang/cs.json +164 -7
- package/assets/lang/de.json +164 -7
- package/assets/plugins/README.md +8 -1
- package/assets/plugins/gates/README.md +1 -1
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/files.js +142 -99
- package/dist/agents/global.js +83 -6
- package/dist/agents/launch.js +12 -5
- package/dist/agents/profiles.js +13 -2
- package/dist/cli/main.js +17 -1
- package/dist/cli/menu-parts.js +140 -0
- package/dist/cli/menu-research.js +194 -0
- package/dist/cli/menu-settings.js +273 -0
- package/dist/cli/menu-waiting.js +79 -0
- package/dist/cli/menu.js +91 -97
- package/dist/cli/registry.js +1 -1
- package/dist/cli/ui.js +162 -6
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +2 -2
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/meta.js +6 -2
- package/dist/commands/research.js +55 -16
- package/dist/commands/session.js +15 -7
- package/dist/commands/setup.js +51 -2
- package/dist/commands/start.js +9 -4
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/config.js +7 -1
- package/dist/core/hooks.js +210 -0
- package/dist/core/install.js +3 -2
- package/dist/core/media.js +21 -7
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/shortcut.js +1 -4
- package/dist/core/tree.js +19 -2
- package/dist/core/which.js +18 -0
- package/dist/runners/claude.js +3 -0
- package/dist/runners/codex.js +14 -5
- package/dist/runners/grok.js +108 -0
- package/dist/runners/index.js +2 -0
- package/dist/runners/opencode.js +8 -4
- 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.
|
|
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
|
-
|
|
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) {
|
package/dist/core/which.js
CHANGED
|
@@ -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);
|
package/dist/runners/claude.js
CHANGED
|
@@ -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
|
}
|
package/dist/runners/codex.js
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
+
};
|
package/dist/runners/index.js
CHANGED
|
@@ -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";
|
package/dist/runners/opencode.js
CHANGED
|
@@ -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);
|
|
5
|
-
//
|
|
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
|
-
|
|
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;
|