strom-research 1.1.1 → 1.3.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 +6 -1
- package/assets/lang/cs.json +177 -6
- package/assets/lang/de.json +177 -6
- package/assets/method/core.md +6 -2
- package/assets/method/reading.md +2 -1
- package/assets/plugins/README.md +16 -2
- package/assets/plugins/gates/README.md +95 -0
- package/assets/plugins/gates/claude-usage/gate.json +10 -0
- package/assets/plugins/gates/claude-usage/gate.ts +125 -0
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/global.js +10 -0
- package/dist/agents/launch.js +1 -1
- package/dist/brief/brief.js +2 -0
- package/dist/cli/main.js +43 -2
- 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 +113 -99
- package/dist/cli/ui.js +175 -5
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +4 -3
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/gates.js +58 -0
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/research.js +60 -17
- package/dist/commands/session.js +161 -18
- package/dist/commands/setup.js +57 -1
- package/dist/commands/sources.js +24 -0
- package/dist/commands/start.js +5 -1
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/clock.js +55 -0
- package/dist/core/config.js +18 -0
- package/dist/core/gate.js +175 -0
- package/dist/core/hooks.js +210 -0
- package/dist/core/media.js +21 -7
- package/dist/core/overview.js +2 -0
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/tree.js +19 -2
- package/dist/runners/antigravity.js +2 -1
- package/dist/runners/claude.js +160 -114
- package/dist/runners/codex.js +14 -1
- package/dist/runners/jsonl.js +63 -37
- package/dist/runners/opencode.js +7 -2
- package/dist/runners/script.js +56 -47
- package/package.json +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Moving the research to another folder (the setting `home`). The trees and the
|
|
2
|
+
// shared folder (connectors, hooks, gates, the inbox, big images) live under it:
|
|
3
|
+
// changing the setting alone would leave them behind — the trees gone from the
|
|
4
|
+
// list, the plugins and images not found. So the whole folder moves with it:
|
|
5
|
+
// renamed where it can be (one disk), else copied and the old one left for the
|
|
6
|
+
// user to delete. Never while somebody works in a tree, never into a folder
|
|
7
|
+
// that holds something already, never into itself.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { loadConsents, saveConsents } from "./connector.js";
|
|
11
|
+
import { liveHolder } from "./lock.js";
|
|
12
|
+
import { liveRunning } from "./live.js";
|
|
13
|
+
import { liveWorkers, runsAtWork } from "./workers.js";
|
|
14
|
+
/** Files a system leaves in any folder: a folder with only these is empty. */
|
|
15
|
+
const LITTER = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
16
|
+
/** What the top of a disk holds of the system's own: the research goes into a folder of its own there. */
|
|
17
|
+
const DISK = new Set([".Spotlight-V100", ".fseventsd", ".Trashes", ".TemporaryItems", ".DocumentRevisions-V100", "System Volume Information", "$RECYCLE.BIN"]);
|
|
18
|
+
function entries(dir) {
|
|
19
|
+
try {
|
|
20
|
+
return fs.readdirSync(dir).filter((n) => !LITTER.has(n));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Is `inner` the folder `outer` or somewhere in it? (Windows: letters of any case are the same.) */
|
|
27
|
+
export function within(inner, outer, platform = process.platform) {
|
|
28
|
+
const [a, b] = platform === "win32" ? [inner.toLowerCase(), outer.toLowerCase()] : [inner, outer];
|
|
29
|
+
const rel = path.relative(b, a);
|
|
30
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
31
|
+
}
|
|
32
|
+
/** The same folder? (Windows: letters of any case are the same.) */
|
|
33
|
+
export function sameFolder(a, b, platform = process.platform) {
|
|
34
|
+
return within(path.resolve(a), path.resolve(b), platform) && within(path.resolve(b), path.resolve(a), platform);
|
|
35
|
+
}
|
|
36
|
+
/** Somebody at work in a tree: an agent present, a run, a command holding its lock, the live bridge. */
|
|
37
|
+
function atWork(root) {
|
|
38
|
+
return liveWorkers(root).length > 0 || runsAtWork(root).length > 0 || Boolean(liveHolder(path.join(root, ".strom", "tree.lock"))) || Boolean(liveRunning(root));
|
|
39
|
+
}
|
|
40
|
+
/** `trees`: the folders of the trees strom knows (those inside `from` move along). */
|
|
41
|
+
export function planMove(from, to, trees) {
|
|
42
|
+
const names = entries(from);
|
|
43
|
+
const inside = trees.filter((t) => within(t, from));
|
|
44
|
+
const busy = inside.filter(atWork).map((t) => path.basename(t));
|
|
45
|
+
// The top of a disk (its own hidden folders only): into a folder named like the old one there.
|
|
46
|
+
const there = entries(to);
|
|
47
|
+
const target = there.length && there.every((n) => DISK.has(n)) ? path.join(to, path.basename(from) || "Strom") : to;
|
|
48
|
+
const plan = { from, to: target, content: names.length > 0, trees: inside.map((t) => path.basename(t)), busy };
|
|
49
|
+
if (!plan.content)
|
|
50
|
+
return plan;
|
|
51
|
+
if (within(target, from) || within(from, target))
|
|
52
|
+
plan.problem = "inside";
|
|
53
|
+
else if (entries(target).length)
|
|
54
|
+
plan.problem = "notEmpty";
|
|
55
|
+
else if (busy.length)
|
|
56
|
+
plan.problem = "busy";
|
|
57
|
+
return plan;
|
|
58
|
+
}
|
|
59
|
+
/** Move the folder; "copied" when it had to be copied to another disk (the old one stays). */
|
|
60
|
+
export function moveHome(plan) {
|
|
61
|
+
fs.mkdirSync(path.dirname(plan.to), { recursive: true });
|
|
62
|
+
if (fs.existsSync(plan.to)) {
|
|
63
|
+
// empty but for what a system leaves (planMove said so)
|
|
64
|
+
for (const n of LITTER)
|
|
65
|
+
fs.rmSync(path.join(plan.to, n), { force: true });
|
|
66
|
+
fs.rmdirSync(plan.to);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
fs.renameSync(plan.from, plan.to);
|
|
70
|
+
return "moved";
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err.code !== "EXDEV")
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
fs.cpSync(plan.from, plan.to, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true, verbatimSymlinks: true });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
fs.rmSync(plan.to, { recursive: true, force: true }); // half a copy is no copy
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return "copied";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* What pointed into the old folder points into the new one: the settings (the folders under it, the tree worked on) and
|
|
87
|
+
* the consents to connectors, which know a connector by its folder.
|
|
88
|
+
*/
|
|
89
|
+
export function repointSettings(settings, env, from, to) {
|
|
90
|
+
const cfg = settings.config;
|
|
91
|
+
const move = (p) => (p && within(p, from) ? path.join(to, path.relative(from, p)) : p);
|
|
92
|
+
cfg.home = to;
|
|
93
|
+
if (cfg.shared)
|
|
94
|
+
cfg.shared = move(cfg.shared);
|
|
95
|
+
if (cfg.trees)
|
|
96
|
+
cfg.trees = move(cfg.trees);
|
|
97
|
+
if (cfg.currentTree)
|
|
98
|
+
cfg.currentTree = move(cfg.currentTree);
|
|
99
|
+
if (cfg.extraTrees)
|
|
100
|
+
cfg.extraTrees = cfg.extraTrees.map((p) => move(p));
|
|
101
|
+
settings.save();
|
|
102
|
+
const consents = loadConsents(env);
|
|
103
|
+
let changed = false;
|
|
104
|
+
for (const c of Object.values(consents.connectors))
|
|
105
|
+
if (c.dir && within(c.dir, from)) {
|
|
106
|
+
c.dir = move(c.dir);
|
|
107
|
+
changed = true;
|
|
108
|
+
}
|
|
109
|
+
if (changed)
|
|
110
|
+
saveConsents(env, consents);
|
|
111
|
+
}
|
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.3.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) {
|
|
@@ -26,7 +26,8 @@ export const antigravityRunner = {
|
|
|
26
26
|
id: "antigravity",
|
|
27
27
|
command: "agy",
|
|
28
28
|
run(opts) {
|
|
29
|
-
|
|
29
|
+
// Its events name no conversation strom could resume: stopped at the time limit, it is just stopped.
|
|
30
|
+
return runJsonLines("agy", antigravityArgs(opts), opts.env, opts, undefined, (msg, heard) => {
|
|
30
31
|
const kind = String(msg.event ?? msg.type ?? "");
|
|
31
32
|
if (kind === "result") {
|
|
32
33
|
const r = (msg.result ?? {});
|
package/dist/runners/claude.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// count), headless or not. Browser tools
|
|
13
13
|
// are on only when a connector fetches through the browser (--chrome), and off
|
|
14
14
|
// otherwise (--no-chrome), whatever Claude Code's own default is.
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
15
16
|
import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
|
|
16
17
|
function describeTool(block) {
|
|
17
18
|
const input = block.input ?? {};
|
|
@@ -40,6 +41,9 @@ export function claudeArgs(opts) {
|
|
|
40
41
|
args.push("--model", opts.model);
|
|
41
42
|
if (opts.chrome !== undefined)
|
|
42
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");
|
|
43
47
|
args.push(...(opts.extraArgs ?? []));
|
|
44
48
|
return args;
|
|
45
49
|
}
|
|
@@ -60,122 +64,164 @@ export function headlessEnv(env, timeoutMs) {
|
|
|
60
64
|
BASH_MAX_TIMEOUT_MS: long,
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
let
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const content = (msg.message?.content ?? []);
|
|
105
|
-
for (const b of content) {
|
|
106
|
-
if (b.type === "tool_use")
|
|
107
|
-
opts.onProgress?.(describeTool(b));
|
|
108
|
-
else if (b.type === "text" && b.text?.trim())
|
|
109
|
-
opts.onProgress?.(b.text.trim().split("\n")[0].slice(0, 160));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
if (msg.type === "result") {
|
|
113
|
-
text = String(msg.result ?? "");
|
|
114
|
-
isError = Boolean(msg.is_error);
|
|
115
|
-
const u = (msg.usage ?? {});
|
|
116
|
-
if (u.input_tokens !== undefined)
|
|
117
|
-
metrics.inputTokens = u.input_tokens;
|
|
118
|
-
if (u.output_tokens !== undefined)
|
|
119
|
-
metrics.outputTokens = u.output_tokens;
|
|
120
|
-
if (u.cache_read_input_tokens !== undefined)
|
|
121
|
-
metrics.cacheReadTokens = u.cache_read_input_tokens;
|
|
122
|
-
if (u.cache_creation_input_tokens !== undefined)
|
|
123
|
-
metrics.cacheWriteTokens = u.cache_creation_input_tokens;
|
|
124
|
-
if (typeof msg.total_cost_usd === "number")
|
|
125
|
-
metrics.costUsd = msg.total_cost_usd;
|
|
126
|
-
if (typeof msg.num_turns === "number")
|
|
127
|
-
metrics.turns = msg.num_turns;
|
|
128
|
-
if (typeof msg.duration_ms === "number")
|
|
129
|
-
metrics.durationMs = msg.duration_ms;
|
|
130
|
-
for (const d of (msg.permission_denials ?? [])) {
|
|
131
|
-
const input = d.tool_input ?? {};
|
|
132
|
-
const what = typeof input.command === "string" ? input.command : typeof input.file_path === "string" ? input.file_path : typeof input.url === "string" ? input.url : "";
|
|
133
|
-
denied.push(`${d.tool_name ?? "tool"}${what ? `: ${String(what).split("\n")[0].slice(0, 120)}` : ""}`);
|
|
134
|
-
}
|
|
135
|
-
if (denied.length)
|
|
136
|
-
metrics.denied = denied.length;
|
|
67
|
+
function attempt(opts, args, input, timeoutMs) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const child = spawnAgent("claude", args, {
|
|
70
|
+
cwd: opts.cwd,
|
|
71
|
+
env: opts.interactive ? opts.env : headlessEnv(opts.env, opts.timeoutMs),
|
|
72
|
+
stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
73
|
+
windowsHide: !opts.interactive,
|
|
74
|
+
});
|
|
75
|
+
// The brief goes in on stdin: no limit on its length on any platform.
|
|
76
|
+
if (!opts.interactive)
|
|
77
|
+
child.stdin?.end(input);
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
const timer = timeoutMs
|
|
80
|
+
? setTimeout(() => {
|
|
81
|
+
timedOut = true;
|
|
82
|
+
stopTree(child);
|
|
83
|
+
}, timeoutMs)
|
|
84
|
+
: undefined;
|
|
85
|
+
const abort = () => stopTree(child);
|
|
86
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
87
|
+
const a = { code: null, timedOut: false, metrics: {}, reported: false, text: "", stderr: "", isError: false, denied: [] };
|
|
88
|
+
let buffer = "";
|
|
89
|
+
const handle = (line) => {
|
|
90
|
+
if (!line.trim())
|
|
91
|
+
return;
|
|
92
|
+
let msg;
|
|
93
|
+
try {
|
|
94
|
+
msg = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (msg.type === "system" && msg.subtype === "init" && typeof msg.model === "string")
|
|
100
|
+
a.metrics.model = msg.model;
|
|
101
|
+
if (msg.type === "assistant") {
|
|
102
|
+
const content = (msg.message?.content ?? []);
|
|
103
|
+
for (const b of content) {
|
|
104
|
+
if (b.type === "tool_use")
|
|
105
|
+
opts.onProgress?.(describeTool(b));
|
|
106
|
+
else if (b.type === "text" && b.text?.trim())
|
|
107
|
+
opts.onProgress?.(b.text.trim().split("\n")[0].slice(0, 160));
|
|
137
108
|
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
109
|
+
}
|
|
110
|
+
if (msg.type === "result") {
|
|
111
|
+
a.reported = true;
|
|
112
|
+
a.text = String(msg.result ?? "");
|
|
113
|
+
a.isError = Boolean(msg.is_error);
|
|
114
|
+
const m = a.metrics;
|
|
115
|
+
const u = (msg.usage ?? {});
|
|
116
|
+
if (u.input_tokens !== undefined)
|
|
117
|
+
m.inputTokens = u.input_tokens;
|
|
118
|
+
if (u.output_tokens !== undefined)
|
|
119
|
+
m.outputTokens = u.output_tokens;
|
|
120
|
+
if (u.cache_read_input_tokens !== undefined)
|
|
121
|
+
m.cacheReadTokens = u.cache_read_input_tokens;
|
|
122
|
+
if (u.cache_creation_input_tokens !== undefined)
|
|
123
|
+
m.cacheWriteTokens = u.cache_creation_input_tokens;
|
|
124
|
+
if (typeof msg.total_cost_usd === "number")
|
|
125
|
+
m.costUsd = msg.total_cost_usd;
|
|
126
|
+
if (typeof msg.num_turns === "number")
|
|
127
|
+
m.turns = msg.num_turns;
|
|
128
|
+
if (typeof msg.duration_ms === "number")
|
|
129
|
+
m.durationMs = msg.duration_ms;
|
|
130
|
+
for (const d of (msg.permission_denials ?? [])) {
|
|
131
|
+
const input = d.tool_input ?? {};
|
|
132
|
+
const what = typeof input.command === "string" ? input.command : typeof input.file_path === "string" ? input.file_path : typeof input.url === "string" ? input.url : "";
|
|
133
|
+
a.denied.push(`${d.tool_name ?? "tool"}${what ? `: ${String(what).split("\n")[0].slice(0, 120)}` : ""}`);
|
|
147
134
|
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
: /log ?in|authenticat|api key|unauthori[sz]ed/i.test(all) && (code ?? 0) !== 0
|
|
173
|
-
? "auth"
|
|
174
|
-
: (code ?? 0) !== 0 || isError
|
|
175
|
-
? "error"
|
|
176
|
-
: "ok";
|
|
177
|
-
resolve({ exitCode: code ?? 1, outcome, text: text || stderr.trim(), metrics, ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}), ...(denied.length ? { denied } : {}) });
|
|
178
|
-
});
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
child.stdout?.on("data", (d) => {
|
|
138
|
+
const s = d.toString("utf8");
|
|
139
|
+
appendLog(opts.logFile, s);
|
|
140
|
+
buffer += s;
|
|
141
|
+
let nl;
|
|
142
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
143
|
+
handle(buffer.slice(0, nl));
|
|
144
|
+
buffer = buffer.slice(nl + 1);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
child.stderr?.on("data", (d) => {
|
|
148
|
+
a.stderr += d.toString("utf8");
|
|
149
|
+
appendLog(opts.logFile, d.toString("utf8"));
|
|
150
|
+
});
|
|
151
|
+
const end = () => {
|
|
152
|
+
if (timer)
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
155
|
+
};
|
|
156
|
+
child.on("error", (err) => {
|
|
157
|
+
end();
|
|
158
|
+
resolve({ ...a, failed: String(err.message) });
|
|
179
159
|
});
|
|
160
|
+
child.on("close", (code) => {
|
|
161
|
+
end();
|
|
162
|
+
if (buffer)
|
|
163
|
+
handle(buffer);
|
|
164
|
+
resolve({ ...a, code, timedOut });
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** Two starts as one session: the time's metrics added up. */
|
|
169
|
+
function together(first, then) {
|
|
170
|
+
const sum = (k) => first[k] === undefined && then[k] === undefined ? {} : { [k]: (first[k] ?? 0) + (then[k] ?? 0) };
|
|
171
|
+
return {
|
|
172
|
+
...((first.model ?? then.model) ? { model: (first.model ?? then.model) } : {}),
|
|
173
|
+
...sum("inputTokens"),
|
|
174
|
+
...sum("outputTokens"),
|
|
175
|
+
...sum("cacheReadTokens"),
|
|
176
|
+
...sum("cacheWriteTokens"),
|
|
177
|
+
...sum("costUsd"),
|
|
178
|
+
...sum("turns"),
|
|
179
|
+
...sum("durationMs"),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export const claudeRunner = {
|
|
183
|
+
id: "claude",
|
|
184
|
+
command: "claude",
|
|
185
|
+
async run(opts) {
|
|
186
|
+
// A headless session has an id of strom's choosing: stopped at its limit, it is resumed to write down what it found.
|
|
187
|
+
const id = opts.interactive ? undefined : randomUUID();
|
|
188
|
+
const args = claudeArgs(opts);
|
|
189
|
+
let a = await attempt(opts, id ? [...args, "--session-id", id] : args, opts.prompt, opts.timeoutMs);
|
|
190
|
+
if (a.failed)
|
|
191
|
+
return { exitCode: 127, outcome: "error", text: a.failed, metrics: a.metrics };
|
|
192
|
+
const timedOut = a.timedOut;
|
|
193
|
+
let metrics = { ...a.metrics, ...(a.reported ? {} : { costPartial: true }) };
|
|
194
|
+
const denied = [...a.denied];
|
|
195
|
+
if (timedOut) {
|
|
196
|
+
const min = Math.round(opts.timeoutMs / 60000);
|
|
197
|
+
if (opts.wrapUp && id && !opts.signal?.aborted) {
|
|
198
|
+
opts.onProgress?.(`time limit reached (${min} min) — the agent gets ${Math.round(opts.wrapUp.ms / 60000)} min to write down what it found`);
|
|
199
|
+
const more = await attempt(opts, [...args, "--resume", id], opts.wrapUp.prompt, opts.wrapUp.ms);
|
|
200
|
+
if (more.timedOut)
|
|
201
|
+
opts.onProgress?.("the agent did not finish in time — stopped");
|
|
202
|
+
metrics = { ...together(metrics, more.metrics), costPartial: true };
|
|
203
|
+
denied.push(...more.denied);
|
|
204
|
+
a = { ...more, code: more.code, stderr: a.stderr + more.stderr, text: more.text || a.text };
|
|
205
|
+
}
|
|
206
|
+
else
|
|
207
|
+
opts.onProgress?.(`time limit reached (${min} min) — the agent was stopped`);
|
|
208
|
+
}
|
|
209
|
+
if (denied.length)
|
|
210
|
+
metrics.denied = denied.length;
|
|
211
|
+
const all = `${a.text}\n${a.stderr}`;
|
|
212
|
+
const limit = looksLikeLimit(all);
|
|
213
|
+
const code = a.code;
|
|
214
|
+
const outcome = opts.signal?.aborted
|
|
215
|
+
? "stopped"
|
|
216
|
+
: timedOut
|
|
217
|
+
? "timeout"
|
|
218
|
+
: limit.limit
|
|
219
|
+
? "limit"
|
|
220
|
+
: /log ?in|authenticat|api key|unauthori[sz]ed/i.test(all) && (code ?? 0) !== 0
|
|
221
|
+
? "auth"
|
|
222
|
+
: (code ?? 0) !== 0 || a.isError
|
|
223
|
+
? "error"
|
|
224
|
+
: "ok";
|
|
225
|
+
return { exitCode: code ?? 1, outcome, text: a.text || a.stderr.trim(), metrics, ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}), ...(denied.length ? { denied } : {}) };
|
|
180
226
|
},
|
|
181
227
|
};
|
package/dist/runners/codex.js
CHANGED
|
@@ -12,11 +12,24 @@ export function codexArgs(opts) {
|
|
|
12
12
|
: ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...(opts.shared ? ["--add-dir", opts.shared] : [])];
|
|
13
13
|
return ["exec", "--json", "--skip-git-repo-check", ...sandbox, ...(opts.model ? ["--model", opts.model] : []), ...(opts.extraArgs ?? []), "-"];
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Arguments that resume a headless session with a message on stdin (exported for tests). `exec resume` has no
|
|
17
|
+
* --sandbox or --add-dir: the same sandbox is set through its configuration.
|
|
18
|
+
*/
|
|
19
|
+
export function codexResumeArgs(id, opts) {
|
|
20
|
+
const sandbox = opts.permissions === "full"
|
|
21
|
+
? ["--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)}]`] : [])];
|
|
23
|
+
return ["exec", "resume", "--json", "--skip-git-repo-check", ...sandbox, ...(opts.model ? ["--model", opts.model] : []), id, "-"];
|
|
24
|
+
}
|
|
15
25
|
export const codexRunner = {
|
|
16
26
|
id: "codex",
|
|
17
27
|
command: "codex",
|
|
18
28
|
run(opts) {
|
|
19
|
-
|
|
29
|
+
const resume = (id, message) => ({ args: codexResumeArgs(id, opts), input: message });
|
|
30
|
+
return runJsonLines("codex", codexArgs(opts), opts.env, opts, resume, (msg, heard) => {
|
|
31
|
+
if (msg.type === "thread.started" && typeof msg.thread_id === "string")
|
|
32
|
+
heard.sessionId = msg.thread_id;
|
|
20
33
|
const item = msg.item;
|
|
21
34
|
if (msg.type === "item.started" && item?.type === "command_execution" && item.command)
|
|
22
35
|
opts.onProgress?.(`$ ${item.command.replace(/^\S*sh -lc '(.*)'$/s, "$1").split("\n")[0].slice(0, 140)}`);
|
package/dist/runners/jsonl.js
CHANGED
|
@@ -2,23 +2,69 @@
|
|
|
2
2
|
// `--output-format stream-json`): the brief on stdin, one event per line on
|
|
3
3
|
// stdout, a time limit, Ctrl-C, the subscription limit and a missing login
|
|
4
4
|
// recognised — the same for every such agent; each runner only reads its own
|
|
5
|
-
// events.
|
|
5
|
+
// events. Stopped at its time limit, an agent that can resume its session gets a
|
|
6
|
+
// few minutes to write down what it found (RunOptions.wrapUp).
|
|
6
7
|
import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
|
|
7
|
-
export function runJsonLines(command, args, env, opts, onEvent) {
|
|
8
|
+
export async function runJsonLines(command, args, env, opts, resume, onEvent) {
|
|
9
|
+
const started = Date.now();
|
|
10
|
+
const heard = { metrics: {}, text: "", isError: false, denied: [] };
|
|
11
|
+
let a = await attempt(command, args, opts.prompt, env, opts, opts.timeoutMs, heard, onEvent);
|
|
12
|
+
if (a.failed)
|
|
13
|
+
return { exitCode: 127, outcome: "error", text: a.failed, metrics: heard.metrics };
|
|
14
|
+
const timedOut = a.timedOut;
|
|
15
|
+
if (timedOut) {
|
|
16
|
+
const min = Math.round(opts.timeoutMs / 60000);
|
|
17
|
+
if (opts.wrapUp && resume && heard.sessionId && !opts.signal?.aborted) {
|
|
18
|
+
opts.onProgress?.(`time limit reached (${min} min) — the agent gets ${Math.round(opts.wrapUp.ms / 60000)} min to write down what it found`);
|
|
19
|
+
const r = resume(heard.sessionId, opts.wrapUp.prompt);
|
|
20
|
+
const more = await attempt(command, r.args, r.input, env, opts, opts.wrapUp.ms, heard, onEvent);
|
|
21
|
+
if (more.timedOut)
|
|
22
|
+
opts.onProgress?.("the agent did not finish in time — stopped");
|
|
23
|
+
a = { ...more, stderr: a.stderr + more.stderr };
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
opts.onProgress?.(`time limit reached (${min} min) — the agent was stopped`);
|
|
27
|
+
}
|
|
28
|
+
const code = a.code;
|
|
29
|
+
heard.metrics.durationMs ??= Date.now() - started;
|
|
30
|
+
if (heard.denied.length)
|
|
31
|
+
heard.metrics.denied = heard.denied.length;
|
|
32
|
+
const all = `${heard.text}\n${a.stderr}`;
|
|
33
|
+
const limit = looksLikeLimit(all);
|
|
34
|
+
const outcome = opts.signal?.aborted
|
|
35
|
+
? "stopped"
|
|
36
|
+
: timedOut
|
|
37
|
+
? "timeout"
|
|
38
|
+
: limit.limit
|
|
39
|
+
? "limit"
|
|
40
|
+
: /log ?in|authenticat|api[ _]?key|unauthori[sz]ed|GEMINI_API_KEY/i.test(all) && ((code ?? 0) !== 0 || heard.isError)
|
|
41
|
+
? "auth"
|
|
42
|
+
: (code ?? 0) !== 0 || heard.isError
|
|
43
|
+
? "error"
|
|
44
|
+
: "ok";
|
|
45
|
+
return {
|
|
46
|
+
exitCode: code ?? 1,
|
|
47
|
+
outcome,
|
|
48
|
+
text: heard.text || a.stderr.trim(),
|
|
49
|
+
metrics: heard.metrics,
|
|
50
|
+
...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
|
|
51
|
+
...(heard.denied.length ? { denied: heard.denied } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** One start of the agent; its events go to `heard`. */
|
|
55
|
+
function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent) {
|
|
8
56
|
return new Promise((resolve) => {
|
|
9
|
-
const started = Date.now();
|
|
10
57
|
const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
11
|
-
child.stdin?.end(
|
|
58
|
+
child.stdin?.end(input);
|
|
12
59
|
let timedOut = false;
|
|
13
|
-
const timer =
|
|
60
|
+
const timer = timeoutMs
|
|
14
61
|
? setTimeout(() => {
|
|
15
62
|
timedOut = true;
|
|
16
|
-
opts.onProgress?.(`time limit reached (${Math.round(opts.timeoutMs / 60000)} min) — stopping the agent`);
|
|
17
63
|
stopTree(child);
|
|
18
|
-
},
|
|
64
|
+
}, timeoutMs)
|
|
19
65
|
: undefined;
|
|
20
|
-
|
|
21
|
-
|
|
66
|
+
const abort = () => stopTree(child);
|
|
67
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
22
68
|
let buffer = "";
|
|
23
69
|
const handle = (line) => {
|
|
24
70
|
if (!line.trim())
|
|
@@ -47,40 +93,20 @@ export function runJsonLines(command, args, env, opts, onEvent) {
|
|
|
47
93
|
stderr += d.toString("utf8");
|
|
48
94
|
appendLog(opts.logFile, d.toString("utf8"));
|
|
49
95
|
});
|
|
50
|
-
|
|
96
|
+
const end = () => {
|
|
51
97
|
if (timer)
|
|
52
98
|
clearTimeout(timer);
|
|
53
|
-
|
|
99
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
100
|
+
};
|
|
101
|
+
child.on("error", (err) => {
|
|
102
|
+
end();
|
|
103
|
+
resolve({ code: 127, timedOut, stderr, failed: String(err.message) });
|
|
54
104
|
});
|
|
55
105
|
child.on("close", (code) => {
|
|
56
|
-
|
|
57
|
-
clearTimeout(timer);
|
|
106
|
+
end();
|
|
58
107
|
if (buffer)
|
|
59
108
|
handle(buffer);
|
|
60
|
-
|
|
61
|
-
if (heard.denied.length)
|
|
62
|
-
heard.metrics.denied = heard.denied.length;
|
|
63
|
-
const all = `${heard.text}\n${stderr}`;
|
|
64
|
-
const limit = looksLikeLimit(all);
|
|
65
|
-
const outcome = opts.signal?.aborted
|
|
66
|
-
? "stopped"
|
|
67
|
-
: timedOut
|
|
68
|
-
? "timeout"
|
|
69
|
-
: limit.limit
|
|
70
|
-
? "limit"
|
|
71
|
-
: /log ?in|authenticat|api[ _]?key|unauthori[sz]ed|GEMINI_API_KEY/i.test(all) && ((code ?? 0) !== 0 || heard.isError)
|
|
72
|
-
? "auth"
|
|
73
|
-
: (code ?? 0) !== 0 || heard.isError
|
|
74
|
-
? "error"
|
|
75
|
-
: "ok";
|
|
76
|
-
resolve({
|
|
77
|
-
exitCode: code ?? 1,
|
|
78
|
-
outcome,
|
|
79
|
-
text: heard.text || stderr.trim(),
|
|
80
|
-
metrics: heard.metrics,
|
|
81
|
-
...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
|
|
82
|
-
...(heard.denied.length ? { denied: heard.denied } : {}),
|
|
83
|
-
});
|
|
109
|
+
resolve({ code, timedOut, stderr });
|
|
84
110
|
});
|
|
85
111
|
});
|
|
86
112
|
}
|
package/dist/runners/opencode.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// evidence and git denied); what they would ask about is refused in a run
|
|
5
5
|
// nobody watches — under ask and auto that is all; full adds --auto
|
|
6
6
|
// (everything the rules do not deny). Events are JSON lines: text, tool_use,
|
|
7
|
-
// step_start, step_finish (tokens and cost of a step), error
|
|
7
|
+
// step_start, step_finish (tokens and cost of a step), error; each names its
|
|
8
|
+
// session (sessionID), which `--session` resumes.
|
|
8
9
|
import { runJsonLines } from "./jsonl.js";
|
|
9
10
|
/** Command-line arguments of a headless run (exported for tests). */
|
|
10
11
|
export function opencodeArgs(opts) {
|
|
@@ -16,8 +17,12 @@ export const opencodeRunner = {
|
|
|
16
17
|
run(opts) {
|
|
17
18
|
const args = opencodeArgs({ ...opts, kickoff: "Your brief is above. Work only through `strom` in this folder, as it says." });
|
|
18
19
|
const tokens = { input: 0, output: 0, cacheRead: 0 };
|
|
19
|
-
|
|
20
|
+
// Resumed: its session by id, the message as the prompt.
|
|
21
|
+
const resume = (id, message) => ({ args: [...opencodeArgs({ ...opts, kickoff: message }).slice(0, -1), "--session", id, message], input: "" });
|
|
22
|
+
return runJsonLines("opencode", args, opts.env, opts, resume, (msg, heard) => {
|
|
20
23
|
const part = (msg.part ?? {});
|
|
24
|
+
if (typeof msg.sessionID === "string")
|
|
25
|
+
heard.sessionId = msg.sessionID;
|
|
21
26
|
if (msg.type === "tool_use") {
|
|
22
27
|
const state = (part.state ?? {});
|
|
23
28
|
const input = state.input ?? {};
|