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.
Files changed (50) hide show
  1. package/README.md +6 -1
  2. package/assets/lang/cs.json +177 -6
  3. package/assets/lang/de.json +177 -6
  4. package/assets/method/core.md +6 -2
  5. package/assets/method/reading.md +2 -1
  6. package/assets/plugins/README.md +16 -2
  7. package/assets/plugins/gates/README.md +95 -0
  8. package/assets/plugins/gates/claude-usage/gate.json +10 -0
  9. package/assets/plugins/gates/claude-usage/gate.ts +125 -0
  10. package/assets/plugins/hooks/README.md +91 -0
  11. package/dist/agents/global.js +10 -0
  12. package/dist/agents/launch.js +1 -1
  13. package/dist/brief/brief.js +2 -0
  14. package/dist/cli/main.js +43 -2
  15. package/dist/cli/menu-parts.js +140 -0
  16. package/dist/cli/menu-research.js +194 -0
  17. package/dist/cli/menu-settings.js +273 -0
  18. package/dist/cli/menu-waiting.js +79 -0
  19. package/dist/cli/menu.js +113 -99
  20. package/dist/cli/ui.js +175 -5
  21. package/dist/cli/wizard.js +48 -2
  22. package/dist/commands/browse.js +4 -3
  23. package/dist/commands/connectors.js +25 -2
  24. package/dist/commands/gates.js +58 -0
  25. package/dist/commands/hooks.js +141 -0
  26. package/dist/commands/index.js +2 -0
  27. package/dist/commands/intake.js +2 -2
  28. package/dist/commands/research.js +60 -17
  29. package/dist/commands/session.js +161 -18
  30. package/dist/commands/setup.js +57 -1
  31. package/dist/commands/sources.js +24 -0
  32. package/dist/commands/start.js +5 -1
  33. package/dist/commands/tasks.js +41 -4
  34. package/dist/core/browser.js +50 -0
  35. package/dist/core/clock.js +55 -0
  36. package/dist/core/config.js +18 -0
  37. package/dist/core/gate.js +175 -0
  38. package/dist/core/hooks.js +210 -0
  39. package/dist/core/media.js +21 -7
  40. package/dist/core/overview.js +2 -0
  41. package/dist/core/phrases.js +1 -0
  42. package/dist/core/relocate.js +111 -0
  43. package/dist/core/tree.js +19 -2
  44. package/dist/runners/antigravity.js +2 -1
  45. package/dist/runners/claude.js +160 -114
  46. package/dist/runners/codex.js +14 -1
  47. package/dist/runners/jsonl.js +63 -37
  48. package/dist/runners/opencode.js +7 -2
  49. package/dist/runners/script.js +56 -47
  50. 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.1.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
- 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,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 ?? {};
@@ -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
- 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;
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
- 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);
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
- 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
- });
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
  };
@@ -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 ?? {};