strom-research 1.1.1 → 1.2.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/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.1.1";
26
+ export const VERSION = "1.2.0";
27
27
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
28
28
  .strom/
29
29
  /media/
@@ -26,7 +26,8 @@ export const antigravityRunner = {
26
26
  id: "antigravity",
27
27
  command: "agy",
28
28
  run(opts) {
29
- return runJsonLines("agy", antigravityArgs(opts), opts.env, opts, (msg, heard) => {
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 ?? {});
@@ -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 ?? {};
@@ -60,122 +61,164 @@ export function headlessEnv(env, timeoutMs) {
60
61
  BASH_MAX_TIMEOUT_MS: long,
61
62
  };
62
63
  }
63
- export const claudeRunner = {
64
- id: "claude",
65
- command: "claude",
66
- run(opts) {
67
- return new Promise((resolve) => {
68
- const child = spawnAgent("claude", claudeArgs(opts), {
69
- cwd: opts.cwd,
70
- env: opts.interactive ? opts.env : headlessEnv(opts.env, opts.timeoutMs),
71
- stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
72
- windowsHide: !opts.interactive,
73
- });
74
- // The brief goes in on stdin: no limit on its length on any platform.
75
- if (!opts.interactive)
76
- child.stdin?.end(opts.prompt);
77
- let timedOut = false;
78
- const timer = opts.timeoutMs
79
- ? setTimeout(() => {
80
- timedOut = true;
81
- opts.onProgress?.(`time limit reached (${Math.round(opts.timeoutMs / 60000)} min) — stopping the agent`);
82
- stopTree(child);
83
- }, opts.timeoutMs)
84
- : undefined;
85
- opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
86
- const metrics = {};
87
- let text = "";
88
- let buffer = "";
89
- let isError = false;
90
- const denied = [];
91
- const handle = (line) => {
92
- if (!line.trim())
93
- return;
94
- let msg;
95
- try {
96
- msg = JSON.parse(line);
97
- }
98
- catch {
99
- return;
100
- }
101
- if (msg.type === "system" && msg.subtype === "init" && typeof msg.model === "string")
102
- metrics.model = msg.model;
103
- if (msg.type === "assistant") {
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;
64
+ function attempt(opts, args, input, timeoutMs) {
65
+ return new Promise((resolve) => {
66
+ const child = spawnAgent("claude", args, {
67
+ cwd: opts.cwd,
68
+ env: opts.interactive ? opts.env : headlessEnv(opts.env, opts.timeoutMs),
69
+ stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
70
+ windowsHide: !opts.interactive,
71
+ });
72
+ // The brief goes in on stdin: no limit on its length on any platform.
73
+ if (!opts.interactive)
74
+ child.stdin?.end(input);
75
+ let timedOut = false;
76
+ const timer = timeoutMs
77
+ ? setTimeout(() => {
78
+ timedOut = true;
79
+ stopTree(child);
80
+ }, timeoutMs)
81
+ : undefined;
82
+ const abort = () => stopTree(child);
83
+ opts.signal?.addEventListener("abort", abort, { once: true });
84
+ const a = { code: null, timedOut: false, metrics: {}, reported: false, text: "", stderr: "", isError: false, denied: [] };
85
+ let buffer = "";
86
+ const handle = (line) => {
87
+ if (!line.trim())
88
+ return;
89
+ let msg;
90
+ try {
91
+ msg = JSON.parse(line);
92
+ }
93
+ catch {
94
+ return;
95
+ }
96
+ if (msg.type === "system" && msg.subtype === "init" && typeof msg.model === "string")
97
+ a.metrics.model = msg.model;
98
+ if (msg.type === "assistant") {
99
+ const content = (msg.message?.content ?? []);
100
+ for (const b of content) {
101
+ if (b.type === "tool_use")
102
+ opts.onProgress?.(describeTool(b));
103
+ else if (b.type === "text" && b.text?.trim())
104
+ opts.onProgress?.(b.text.trim().split("\n")[0].slice(0, 160));
137
105
  }
138
- };
139
- child.stdout?.on("data", (d) => {
140
- const s = d.toString("utf8");
141
- appendLog(opts.logFile, s);
142
- buffer += s;
143
- let nl;
144
- while ((nl = buffer.indexOf("\n")) >= 0) {
145
- handle(buffer.slice(0, nl));
146
- buffer = buffer.slice(nl + 1);
106
+ }
107
+ if (msg.type === "result") {
108
+ a.reported = true;
109
+ a.text = String(msg.result ?? "");
110
+ a.isError = Boolean(msg.is_error);
111
+ const m = a.metrics;
112
+ const u = (msg.usage ?? {});
113
+ if (u.input_tokens !== undefined)
114
+ m.inputTokens = u.input_tokens;
115
+ if (u.output_tokens !== undefined)
116
+ m.outputTokens = u.output_tokens;
117
+ if (u.cache_read_input_tokens !== undefined)
118
+ m.cacheReadTokens = u.cache_read_input_tokens;
119
+ if (u.cache_creation_input_tokens !== undefined)
120
+ m.cacheWriteTokens = u.cache_creation_input_tokens;
121
+ if (typeof msg.total_cost_usd === "number")
122
+ m.costUsd = msg.total_cost_usd;
123
+ if (typeof msg.num_turns === "number")
124
+ m.turns = msg.num_turns;
125
+ if (typeof msg.duration_ms === "number")
126
+ m.durationMs = msg.duration_ms;
127
+ for (const d of (msg.permission_denials ?? [])) {
128
+ const input = d.tool_input ?? {};
129
+ const what = typeof input.command === "string" ? input.command : typeof input.file_path === "string" ? input.file_path : typeof input.url === "string" ? input.url : "";
130
+ a.denied.push(`${d.tool_name ?? "tool"}${what ? `: ${String(what).split("\n")[0].slice(0, 120)}` : ""}`);
147
131
  }
148
- });
149
- let stderr = "";
150
- child.stderr?.on("data", (d) => {
151
- stderr += d.toString("utf8");
152
- appendLog(opts.logFile, d.toString("utf8"));
153
- });
154
- child.on("error", (err) => {
155
- if (timer)
156
- clearTimeout(timer);
157
- resolve({ exitCode: 127, outcome: "error", text: String(err.message), metrics });
158
- });
159
- child.on("close", (code) => {
160
- if (timer)
161
- clearTimeout(timer);
162
- if (buffer)
163
- handle(buffer);
164
- const all = `${text}\n${stderr}`;
165
- const limit = looksLikeLimit(all);
166
- const outcome = opts.signal?.aborted
167
- ? "stopped"
168
- : timedOut
169
- ? "timeout"
170
- : limit.limit
171
- ? "limit"
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
- });
132
+ }
133
+ };
134
+ child.stdout?.on("data", (d) => {
135
+ const s = d.toString("utf8");
136
+ appendLog(opts.logFile, s);
137
+ buffer += s;
138
+ let nl;
139
+ while ((nl = buffer.indexOf("\n")) >= 0) {
140
+ handle(buffer.slice(0, nl));
141
+ buffer = buffer.slice(nl + 1);
142
+ }
143
+ });
144
+ child.stderr?.on("data", (d) => {
145
+ a.stderr += d.toString("utf8");
146
+ appendLog(opts.logFile, d.toString("utf8"));
147
+ });
148
+ const end = () => {
149
+ if (timer)
150
+ clearTimeout(timer);
151
+ opts.signal?.removeEventListener("abort", abort);
152
+ };
153
+ child.on("error", (err) => {
154
+ end();
155
+ resolve({ ...a, failed: String(err.message) });
179
156
  });
157
+ child.on("close", (code) => {
158
+ end();
159
+ if (buffer)
160
+ handle(buffer);
161
+ resolve({ ...a, code, timedOut });
162
+ });
163
+ });
164
+ }
165
+ /** Two starts as one session: the time's metrics added up. */
166
+ function together(first, then) {
167
+ const sum = (k) => first[k] === undefined && then[k] === undefined ? {} : { [k]: (first[k] ?? 0) + (then[k] ?? 0) };
168
+ return {
169
+ ...((first.model ?? then.model) ? { model: (first.model ?? then.model) } : {}),
170
+ ...sum("inputTokens"),
171
+ ...sum("outputTokens"),
172
+ ...sum("cacheReadTokens"),
173
+ ...sum("cacheWriteTokens"),
174
+ ...sum("costUsd"),
175
+ ...sum("turns"),
176
+ ...sum("durationMs"),
177
+ };
178
+ }
179
+ export const claudeRunner = {
180
+ id: "claude",
181
+ command: "claude",
182
+ async run(opts) {
183
+ // A headless session has an id of strom's choosing: stopped at its limit, it is resumed to write down what it found.
184
+ const id = opts.interactive ? undefined : randomUUID();
185
+ const args = claudeArgs(opts);
186
+ let a = await attempt(opts, id ? [...args, "--session-id", id] : args, opts.prompt, opts.timeoutMs);
187
+ if (a.failed)
188
+ return { exitCode: 127, outcome: "error", text: a.failed, metrics: a.metrics };
189
+ const timedOut = a.timedOut;
190
+ let metrics = { ...a.metrics, ...(a.reported ? {} : { costPartial: true }) };
191
+ const denied = [...a.denied];
192
+ if (timedOut) {
193
+ const min = Math.round(opts.timeoutMs / 60000);
194
+ if (opts.wrapUp && id && !opts.signal?.aborted) {
195
+ opts.onProgress?.(`time limit reached (${min} min) — the agent gets ${Math.round(opts.wrapUp.ms / 60000)} min to write down what it found`);
196
+ const more = await attempt(opts, [...args, "--resume", id], opts.wrapUp.prompt, opts.wrapUp.ms);
197
+ if (more.timedOut)
198
+ opts.onProgress?.("the agent did not finish in time — stopped");
199
+ metrics = { ...together(metrics, more.metrics), costPartial: true };
200
+ denied.push(...more.denied);
201
+ a = { ...more, code: more.code, stderr: a.stderr + more.stderr, text: more.text || a.text };
202
+ }
203
+ else
204
+ opts.onProgress?.(`time limit reached (${min} min) — the agent was stopped`);
205
+ }
206
+ if (denied.length)
207
+ metrics.denied = denied.length;
208
+ const all = `${a.text}\n${a.stderr}`;
209
+ const limit = looksLikeLimit(all);
210
+ const code = a.code;
211
+ const outcome = opts.signal?.aborted
212
+ ? "stopped"
213
+ : timedOut
214
+ ? "timeout"
215
+ : limit.limit
216
+ ? "limit"
217
+ : /log ?in|authenticat|api key|unauthori[sz]ed/i.test(all) && (code ?? 0) !== 0
218
+ ? "auth"
219
+ : (code ?? 0) !== 0 || a.isError
220
+ ? "error"
221
+ : "ok";
222
+ return { exitCode: code ?? 1, outcome, text: a.text || a.stderr.trim(), metrics, ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}), ...(denied.length ? { denied } : {}) };
180
223
  },
181
224
  };
@@ -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
- return runJsonLines("codex", codexArgs(opts), opts.env, opts, (msg, heard) => {
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)}`);
@@ -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(opts.prompt);
58
+ child.stdin?.end(input);
12
59
  let timedOut = false;
13
- const timer = opts.timeoutMs
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
- }, opts.timeoutMs)
64
+ }, timeoutMs)
19
65
  : undefined;
20
- opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
21
- const heard = { metrics: {}, text: "", isError: false, denied: [] };
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
- child.on("error", (err) => {
96
+ const end = () => {
51
97
  if (timer)
52
98
  clearTimeout(timer);
53
- resolve({ exitCode: 127, outcome: "error", text: String(err.message), metrics: heard.metrics });
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
- if (timer)
57
- clearTimeout(timer);
106
+ end();
58
107
  if (buffer)
59
108
  handle(buffer);
60
- heard.metrics.durationMs ??= Date.now() - started;
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
  }
@@ -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
- return runJsonLines("opencode", args, opts.env, opts, (msg, heard) => {
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 ?? {};
@@ -7,52 +7,61 @@ import { appendLog, looksLikeLimit, stopTree } from "./runner.js";
7
7
  export const scriptRunner = {
8
8
  id: "script",
9
9
  command: process.execPath,
10
- run(opts) {
11
- const script = opts.env.STROM_RUNNER_SCRIPT;
12
- return new Promise((resolve) => {
13
- if (!script)
14
- return resolve({ exitCode: 2, outcome: "error", text: "STROM_RUNNER_SCRIPT is not set", metrics: {} });
15
- const child = spawn(process.execPath, [script, ...(opts.extraArgs ?? [])], {
16
- cwd: opts.cwd,
17
- env: { ...opts.env, STROM_PROMPT: opts.prompt },
18
- stdio: ["pipe", "pipe", "pipe"],
19
- });
20
- child.stdin.end(opts.prompt);
21
- let timedOut = false;
22
- const timer = opts.timeoutMs
23
- ? setTimeout(() => {
24
- timedOut = true;
25
- stopTree(child);
26
- }, opts.timeoutMs)
27
- : undefined;
28
- opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
29
- let out = "";
30
- const denied = [];
31
- const on = (d) => {
32
- for (const l of d.toString("utf8").split("\n"))
33
- if (l.startsWith("denied: "))
34
- denied.push(l.slice(8).trim());
35
- out += d.toString("utf8");
36
- appendLog(opts.logFile, d.toString("utf8"));
37
- for (const l of d.toString("utf8").split("\n"))
38
- if (l.trim())
39
- opts.onProgress?.(l.trim().slice(0, 160));
40
- };
41
- child.stdout.on("data", on);
42
- child.stderr.on("data", on);
43
- child.on("close", (code) => {
44
- if (timer)
45
- clearTimeout(timer);
46
- const limit = looksLikeLimit(out);
47
- resolve({
48
- exitCode: code ?? 1,
49
- outcome: opts.signal?.aborted ? "stopped" : timedOut ? "timeout" : limit.limit ? "limit" : code === 0 ? "ok" : "error",
50
- text: out.trim().split("\n").slice(-5).join("\n"),
51
- metrics: { turns: 1, ...(denied.length ? { denied: denied.length } : {}) },
52
- ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
53
- ...(denied.length ? { denied } : {}),
54
- });
55
- });
56
- });
10
+ async run(opts) {
11
+ const first = await once(opts, opts.prompt, opts.timeoutMs, false);
12
+ if (first.outcome !== "timeout" || !opts.wrapUp || opts.signal?.aborted)
13
+ return first;
14
+ // Stopped at its limit: resumed with the message to write down what it found (STROM_WRAP_UP=1).
15
+ opts.onProgress?.("time limit reached — the agent gets time to write down what it found");
16
+ const more = await once(opts, opts.wrapUp.prompt, opts.wrapUp.ms, true);
17
+ return { ...more, outcome: "timeout" };
57
18
  },
58
19
  };
20
+ function once(opts, prompt, timeoutMs, wrapUp) {
21
+ const script = opts.env.STROM_RUNNER_SCRIPT;
22
+ return new Promise((resolve) => {
23
+ if (!script)
24
+ return resolve({ exitCode: 2, outcome: "error", text: "STROM_RUNNER_SCRIPT is not set", metrics: {} });
25
+ const child = spawn(process.execPath, [script, ...(opts.extraArgs ?? [])], {
26
+ cwd: opts.cwd,
27
+ env: { ...opts.env, STROM_PROMPT: prompt, ...(wrapUp ? { STROM_WRAP_UP: "1" } : {}) },
28
+ stdio: ["pipe", "pipe", "pipe"],
29
+ });
30
+ child.stdin.end(prompt);
31
+ let timedOut = false;
32
+ const timer = timeoutMs
33
+ ? setTimeout(() => {
34
+ timedOut = true;
35
+ stopTree(child);
36
+ }, timeoutMs)
37
+ : undefined;
38
+ opts.signal?.addEventListener("abort", () => stopTree(child), { once: true });
39
+ let out = "";
40
+ const denied = [];
41
+ const on = (d) => {
42
+ for (const l of d.toString("utf8").split("\n"))
43
+ if (l.startsWith("denied: "))
44
+ denied.push(l.slice(8).trim());
45
+ out += d.toString("utf8");
46
+ appendLog(opts.logFile, d.toString("utf8"));
47
+ for (const l of d.toString("utf8").split("\n"))
48
+ if (l.trim())
49
+ opts.onProgress?.(l.trim().slice(0, 160));
50
+ };
51
+ child.stdout.on("data", on);
52
+ child.stderr.on("data", on);
53
+ child.on("close", (code) => {
54
+ if (timer)
55
+ clearTimeout(timer);
56
+ const limit = looksLikeLimit(out);
57
+ resolve({
58
+ exitCode: code ?? 1,
59
+ outcome: opts.signal?.aborted ? "stopped" : timedOut ? "timeout" : limit.limit ? "limit" : code === 0 ? "ok" : "error",
60
+ text: out.trim().split("\n").slice(-5).join("\n"),
61
+ metrics: { turns: 1, ...(denied.length ? { denied: denied.length } : {}) },
62
+ ...(limit.resumeAt ? { resumeAt: limit.resumeAt } : {}),
63
+ ...(denied.length ? { denied } : {}),
64
+ });
65
+ });
66
+ });
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {