strom-research 1.7.0 → 1.8.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 +3 -0
- package/assets/lang/cs.json +117 -2
- package/assets/lang/de.json +117 -2
- package/assets/method/narrate.md +5 -1
- package/assets/plugins/gates/claude-usage/gate.ts +2 -1
- package/dist/agents/files.js +6 -4
- package/dist/cli/main.js +21 -3
- package/dist/cli/menu-links.js +35 -1
- package/dist/cli/menu-research.js +41 -2
- package/dist/cli/menu-settings.js +17 -1
- package/dist/cli/menu.js +19 -8
- package/dist/cli/ui.js +57 -1
- package/dist/commands/guide.js +6 -1
- package/dist/commands/links.js +39 -3
- package/dist/commands/pack.js +21 -18
- package/dist/commands/research.js +42 -1
- package/dist/commands/session.js +79 -9
- package/dist/commands/setup.js +3 -0
- package/dist/commands/start.js +6 -3
- package/dist/commands/story.js +47 -6
- package/dist/commands/tasks.js +15 -6
- package/dist/commands/trees.js +82 -2
- package/dist/core/changelog.js +223 -0
- package/dist/core/clock.js +20 -0
- package/dist/core/config.js +2 -0
- package/dist/core/connector.js +25 -1
- package/dist/core/directions.js +41 -0
- package/dist/core/links.js +17 -4
- package/dist/core/live.js +393 -50
- package/dist/core/pack.js +31 -0
- package/dist/core/phrases.js +61 -0
- package/dist/core/queue.js +7 -2
- package/dist/core/relocate.js +4 -4
- package/dist/core/session.js +8 -2
- package/dist/core/trash.js +72 -0
- package/dist/core/tree.js +1 -1
- package/dist/core/workers.js +28 -3
- package/dist/runners/claude.js +2 -1
- package/dist/runners/jsonl.js +2 -2
- package/dist/runners/runner.js +16 -3
- package/dist/runners/script.js +2 -1
- package/package.json +1 -1
package/dist/core/pack.js
CHANGED
|
@@ -309,3 +309,34 @@ export function unpack(pack, treesDir, shared, env) {
|
|
|
309
309
|
throw e;
|
|
310
310
|
}
|
|
311
311
|
}
|
|
312
|
+
/** The files of the shared store a tree names (its images, the family's material kept there), by its folder alone. */
|
|
313
|
+
export function sharedFilesOf(root) {
|
|
314
|
+
const out = new Map();
|
|
315
|
+
const read = (dir, f) => {
|
|
316
|
+
let names = [];
|
|
317
|
+
try {
|
|
318
|
+
names = fs.readdirSync(path.join(root, "data", dir)).filter((n) => n.endsWith(".json"));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const n of names)
|
|
324
|
+
try {
|
|
325
|
+
f(JSON.parse(fs.readFileSync(path.join(root, "data", dir, n), "utf8")));
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// an unreadable record names nothing
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
read("images", (m) => m.file && out.set(m.file, m.size ?? 0));
|
|
332
|
+
read("inputs", (i) => i.file?.startsWith("media:") && out.set(i.file.slice(6), i.size ?? 0));
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
/** The files of the shared store only this tree names — none of the other trees (their folders) does. */
|
|
336
|
+
export function onlyItsFiles(root, others, shared) {
|
|
337
|
+
const theirs = new Set();
|
|
338
|
+
for (const o of others)
|
|
339
|
+
for (const f of sharedFilesOf(o).keys())
|
|
340
|
+
theirs.add(f);
|
|
341
|
+
return [...sharedFilesOf(root)].filter(([f]) => !theirs.has(f) && fs.existsSync(path.join(shared, f))).map(([file, size]) => ({ file, size }));
|
|
342
|
+
}
|
package/dist/core/phrases.js
CHANGED
|
@@ -78,6 +78,67 @@ export const PHRASES = {
|
|
|
78
78
|
"intake.done.read": "what it adds to the tree is entered as leads citing it (strom source add … --kind family-tree --form authored --input {id}) — or the input is skipped with a reason (strom input skip {id} --reason …)",
|
|
79
79
|
"intake.done": "every person and fact in it is recorded (a source if it is an original document, leads otherwise) and cited — or the input is skipped with a reason (strom input skip I… --reason …)",
|
|
80
80
|
"task.answer": "The user answered (to: {asked}): {answer}",
|
|
81
|
+
// who is at work, for the Strom app (the bridge's working)
|
|
82
|
+
"who.run": "{agent} on its own",
|
|
83
|
+
"who.chat": "{agent} conversation",
|
|
84
|
+
// what a commit saved, for the Strom app (core/changelog.ts)
|
|
85
|
+
"log.person.add": "New person: {who}",
|
|
86
|
+
"log.person.name": "Name recorded: {who}",
|
|
87
|
+
"log.person.edit": "Changed: {who}",
|
|
88
|
+
"log.person.cite": "A record for {who}",
|
|
89
|
+
"log.person.retract": "Withdrawn: {who}",
|
|
90
|
+
"log.merge": "Two records of one merged: {who}",
|
|
91
|
+
"log.family.add": "New family: {who}",
|
|
92
|
+
"log.family.child": "A child of {who}: {child}",
|
|
93
|
+
"log.family.edit": "Family changed: {who}",
|
|
94
|
+
"log.family.cite": "A record for the family {who}",
|
|
95
|
+
"log.event.add": "{who} – {fact}",
|
|
96
|
+
"log.event.edit": "Refined: {who} – {fact}",
|
|
97
|
+
"log.event.cite": "A record for: {who} – {fact}",
|
|
98
|
+
"log.event.retract": "Withdrawn: {who} – {fact}",
|
|
99
|
+
"log.source.add": "Record from a source: {name}",
|
|
100
|
+
"log.source.edit": "Record changed: {name}",
|
|
101
|
+
"log.search.found": "Searched, found: {name}",
|
|
102
|
+
"log.search.negative": "Searched, nothing there: {name}",
|
|
103
|
+
"log.search.partial": "Searched, found in part: {name}",
|
|
104
|
+
"log.search.inconclusive": "Searched, not clear: {name}",
|
|
105
|
+
"log.recordset.add": "New book: {name}",
|
|
106
|
+
"log.recordset.edit": "Book changed: {name}",
|
|
107
|
+
"log.recordset.calibrate": "Which years are where in the book: {name}",
|
|
108
|
+
"log.media": "New images: {name} (images {images})",
|
|
109
|
+
"log.media.one": "New image: {name} (image {image})",
|
|
110
|
+
"log.media.loose": "New images: {n}",
|
|
111
|
+
"log.reading": "Read through: {name} (images {images})",
|
|
112
|
+
"log.task.add": "New task: {name}",
|
|
113
|
+
"log.task.done": "Done: {name}",
|
|
114
|
+
"log.task.park": "Put aside: {name}",
|
|
115
|
+
"log.task.drop": "Dropped: {name}",
|
|
116
|
+
"log.task.wait": "Waits for you: {name}",
|
|
117
|
+
"log.task.wake": "Back to the agent: {name}",
|
|
118
|
+
"log.task.edit": "Task changed: {name}",
|
|
119
|
+
"log.session.start": "The agent began: {name}",
|
|
120
|
+
"log.session.close": "The agent finished: {name}",
|
|
121
|
+
"log.lesson.add": "Learned: {name}",
|
|
122
|
+
"log.hypothesis.add": "A question weighed: {name}",
|
|
123
|
+
"log.hypothesis.argue": "Weighed further: {name}",
|
|
124
|
+
"log.hypothesis.decide": "Decided: {name}",
|
|
125
|
+
"log.conflict.add": "The records disagree: {name}",
|
|
126
|
+
"log.conflict.resolve": "Disagreement settled: {name}",
|
|
127
|
+
"log.place.add": "New place: {name}",
|
|
128
|
+
"log.place.edit": "Place refined: {name}",
|
|
129
|
+
"log.repository.add": "New archive: {name}",
|
|
130
|
+
"log.repository.edit": "Archive changed: {name}",
|
|
131
|
+
"log.story.set": "Story: {who}",
|
|
132
|
+
"log.story.approve": "Story approved: {who}",
|
|
133
|
+
"log.note.add": "Note: {name}",
|
|
134
|
+
"log.input.add": "Material from the family: {name}",
|
|
135
|
+
"log.input.sync": "Family tree taken in: {name}",
|
|
136
|
+
"log.input.done": "Material gone through: {name}",
|
|
137
|
+
"log.research.add": "New direction of the research: {name}",
|
|
138
|
+
"log.research.edit": "Direction changed: {name}",
|
|
139
|
+
"log.research.pause": "Direction paused: {name}",
|
|
140
|
+
"log.research.resume": "Direction taken up again: {name}",
|
|
141
|
+
"log.research.done": "Direction ended: {name}",
|
|
81
142
|
};
|
|
82
143
|
const catalogs = new Map();
|
|
83
144
|
/**
|
package/dist/core/queue.js
CHANGED
|
@@ -12,6 +12,7 @@ import { parentsOf } from "./people.js";
|
|
|
12
12
|
import { taskRecordsets } from "./frontier.js";
|
|
13
13
|
import { now } from "./tree.js";
|
|
14
14
|
import { subjectPeople } from "./records.js";
|
|
15
|
+
import { directionOf, scopes } from "./directions.js";
|
|
15
16
|
export const LEVEL_ORDER = { intake: 0, locate: 1, link: 2, verify: 3, enrich: 4, request: 5, narrate: 6 };
|
|
16
17
|
/** Levels that read records: without images of their record sets they can only ask the user for them. */
|
|
17
18
|
const NEEDS_IMAGES = new Set(["link", "verify"]);
|
|
@@ -53,9 +54,13 @@ export function ancestorLines(tree, focus, max = 50) {
|
|
|
53
54
|
/** The open tasks in the order to work on them, each with the reason. */
|
|
54
55
|
export function rankTasks(tree, tasks, strategy = "balanced", opts = {}) {
|
|
55
56
|
const today = now().slice(0, 10);
|
|
56
|
-
const open = tasks.filter((t) => ["open", "doing"].includes(effectiveState(t, today)));
|
|
57
|
-
const withImages = new Set(tree.list("media").map((m) => m.recordset));
|
|
58
57
|
const researches = tree.list("research");
|
|
58
|
+
// a direction the user paused or ended: its tasks wait (one at work goes on)
|
|
59
|
+
const stopped = new Set(researches.filter((r) => r.state !== "active").map((r) => r.id));
|
|
60
|
+
const all = stopped.size ? scopes(tree) : [];
|
|
61
|
+
const waits = (t) => t.state !== "doing" && stopped.size > 0 && stopped.has(directionOf(tree, t, all) ?? "");
|
|
62
|
+
const open = tasks.filter((t) => ["open", "doing"].includes(effectiveState(t, today)) && !waits(t));
|
|
63
|
+
const withImages = new Set(tree.list("media").map((m) => m.recordset));
|
|
59
64
|
const lines = new Map();
|
|
60
65
|
const linesOf = (researchId) => {
|
|
61
66
|
const r = researches.find((x) => x.id === researchId) ?? (researches.length === 1 ? researches[0] : undefined);
|
package/dist/core/relocate.js
CHANGED
|
@@ -33,15 +33,15 @@ export function within(inner, outer, platform = process.platform) {
|
|
|
33
33
|
export function sameFolder(a, b, platform = process.platform) {
|
|
34
34
|
return within(path.resolve(a), path.resolve(b), platform) && within(path.resolve(b), path.resolve(a), platform);
|
|
35
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));
|
|
36
|
+
/** Somebody at work in a tree: an agent present, a run, a command holding its lock, the live bridge (unless `bridge: false`: it only reads). */
|
|
37
|
+
export function atWork(root, opts = {}) {
|
|
38
|
+
return liveWorkers(root).length > 0 || runsAtWork(root).length > 0 || Boolean(liveHolder(path.join(root, ".strom", "tree.lock"))) || (opts.bridge !== false && Boolean(liveRunning(root)));
|
|
39
39
|
}
|
|
40
40
|
/** `trees`: the folders of the trees strom knows (those inside `from` move along). */
|
|
41
41
|
export function planMove(from, to, trees) {
|
|
42
42
|
const names = entries(from);
|
|
43
43
|
const inside = trees.filter((t) => within(t, from));
|
|
44
|
-
const busy = inside.filter(atWork).map((t) => path.basename(t));
|
|
44
|
+
const busy = inside.filter((t) => atWork(t)).map((t) => path.basename(t));
|
|
45
45
|
// The top of a disk (its own hidden folders only): into a folder named like the old one there.
|
|
46
46
|
const there = entries(to);
|
|
47
47
|
const target = there.length && there.every((n) => DISK.has(n)) ? path.join(to, path.basename(from) || "Strom") : to;
|
package/dist/core/session.js
CHANGED
|
@@ -11,6 +11,7 @@ import { UsageError } from "./errors.js";
|
|
|
11
11
|
import { now, VERSION } from "./tree.js";
|
|
12
12
|
import { makeNote } from "./actions.js";
|
|
13
13
|
import { detectAgent } from "./which.js";
|
|
14
|
+
import { finishAsked, finishFile } from "./clock.js";
|
|
14
15
|
import { isLiveWorker, isWorkerId } from "./workers.js";
|
|
15
16
|
function currentFile(tree, env = tree.env) {
|
|
16
17
|
const w = env.STROM_WORKER;
|
|
@@ -121,7 +122,7 @@ export function sessionNote(tree, s, text) {
|
|
|
121
122
|
export function closeSession(tree, s, input) {
|
|
122
123
|
if (!input.interrupted && (!input.summary.trim() || !input.next.trim()))
|
|
123
124
|
throw new UsageError("closing needs --summary and --next", { hint: 'e.g. --summary "found the baptism, parents Josef and Marie" --next "marriage of Josef ~1898 in B0002"' });
|
|
124
|
-
|
|
125
|
+
const closed = tree.withTreeLock(() => {
|
|
125
126
|
s = tree.get(s.id) ?? s;
|
|
126
127
|
const task = s.task ? tree.get(s.task) : undefined;
|
|
127
128
|
if (task && task.state === "doing") {
|
|
@@ -140,7 +141,8 @@ export function closeSession(tree, s, input) {
|
|
|
140
141
|
...(input.summary.trim() ? { summary: input.summary.trim() } : {}),
|
|
141
142
|
...(input.next.trim() ? { next: input.next.trim() } : {}),
|
|
142
143
|
...(input.metrics ? { metrics: input.metrics } : {}),
|
|
143
|
-
|
|
144
|
+
// asked to finish by the user (strom session finish): theirs, and a run stops after it
|
|
145
|
+
...(input.endedBy ? { endedBy: input.endedBy } : finishAsked(tree.root, s.id) ? { endedBy: "user" } : {}),
|
|
144
146
|
};
|
|
145
147
|
tree.put(closed, { op: "session.close", targets: [s.id], summary: `${s.id} ${closed.state}: ${(input.summary || "no summary").slice(0, 80)}` });
|
|
146
148
|
// Only the session this agent calls its current one is forgotten — not another agent's.
|
|
@@ -148,6 +150,10 @@ export function closeSession(tree, s, input) {
|
|
|
148
150
|
setCurrent(tree, undefined);
|
|
149
151
|
return closed;
|
|
150
152
|
});
|
|
153
|
+
// asked to finish (strom session finish): done
|
|
154
|
+
if (!tree.dryRun)
|
|
155
|
+
fs.rmSync(finishFile(tree.root, closed.id), { force: true });
|
|
156
|
+
return closed;
|
|
151
157
|
}
|
|
152
158
|
/** The last closed sessions (newest first), optionally of one research. */
|
|
153
159
|
export function recentSessions(tree, research, n = 2) {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Into the system's trash, never deleted for good: a person can take it back.
|
|
2
|
+
// macOS: ~/.Trash (Finder's own delete when it lies on another disk); Windows:
|
|
3
|
+
// the Recycle Bin (PowerShell's VisualBasic FileSystem); Linux: gio trash, else
|
|
4
|
+
// the XDG trash folder of the user's home.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { StromError } from "./errors.js";
|
|
9
|
+
import { userHome } from "./paths.js";
|
|
10
|
+
function free(dir, name) {
|
|
11
|
+
let to = path.join(dir, name);
|
|
12
|
+
for (let n = 2; fs.existsSync(to); n++)
|
|
13
|
+
to = path.join(dir, `${name} (${n})`);
|
|
14
|
+
return to;
|
|
15
|
+
}
|
|
16
|
+
function run(command, args, env) {
|
|
17
|
+
const r = spawnSync(command, args, { stdio: "ignore", windowsHide: true, timeout: 120_000, env: { ...process.env, ...env } });
|
|
18
|
+
return !r.error && r.status === 0;
|
|
19
|
+
}
|
|
20
|
+
/** Move a file or folder into the trash; `name` is what it is called there. Throws when no trash takes it. */
|
|
21
|
+
export function moveToTrash(target, env, name = path.basename(target), platform = process.platform) {
|
|
22
|
+
if (!fs.existsSync(target))
|
|
23
|
+
return;
|
|
24
|
+
const fail = () => new StromError(`could not put ${target} into the trash`, { hint: "move the folder into the trash yourself" });
|
|
25
|
+
if (env.STROM_TRASH) {
|
|
26
|
+
// a test's own trash: nothing of the computer's
|
|
27
|
+
fs.mkdirSync(env.STROM_TRASH, { recursive: true });
|
|
28
|
+
fs.renameSync(target, free(env.STROM_TRASH, name));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (platform === "darwin") {
|
|
32
|
+
const trash = path.join(userHome(env), ".Trash");
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(trash, { recursive: true });
|
|
35
|
+
fs.renameSync(target, free(trash, name));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== "EXDEV")
|
|
40
|
+
throw fail();
|
|
41
|
+
}
|
|
42
|
+
// on another disk: Finder puts it into that disk's own trash
|
|
43
|
+
const script = `tell application "Finder" to delete (POSIX file ${JSON.stringify(target)} as alias)`;
|
|
44
|
+
if (!run("osascript", ["-e", script], env))
|
|
45
|
+
throw fail();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (platform === "win32") {
|
|
49
|
+
const kind = fs.statSync(target).isDirectory() ? "DeleteDirectory" : "DeleteFile";
|
|
50
|
+
const lit = `'${target.replace(/'/g, "''")}'`;
|
|
51
|
+
const ps = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::${kind}(${lit}, 'OnlyErrorDialogs', 'SendToRecycleBin')`;
|
|
52
|
+
if (!run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", ps], env) || fs.existsSync(target))
|
|
53
|
+
throw fail();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (run("gio", ["trash", target], env) && !fs.existsSync(target))
|
|
57
|
+
return;
|
|
58
|
+
// the XDG trash of the home: the file, and a note of where it came from
|
|
59
|
+
const base = env.XDG_DATA_HOME || path.join(userHome(env), ".local", "share");
|
|
60
|
+
const files = path.join(base, "Trash", "files");
|
|
61
|
+
const info = path.join(base, "Trash", "info");
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(files, { recursive: true });
|
|
64
|
+
fs.mkdirSync(info, { recursive: true });
|
|
65
|
+
const to = free(files, name);
|
|
66
|
+
fs.renameSync(target, to);
|
|
67
|
+
fs.writeFileSync(path.join(info, `${path.basename(to)}.trashinfo`), `[Trash Info]\nPath=${encodeURI(target)}\nDeletionDate=${new Date().toISOString().slice(0, 19)}\n`);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw fail();
|
|
71
|
+
}
|
|
72
|
+
}
|
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.8.0";
|
|
27
27
|
const GITIGNORE = `# Strom Research — local state and large files are not versioned
|
|
28
28
|
.strom/
|
|
29
29
|
/media/
|
package/dist/core/workers.js
CHANGED
|
@@ -9,6 +9,26 @@ import { acquireLock, liveHolder } from "./lock.js";
|
|
|
9
9
|
function dir(root) {
|
|
10
10
|
return path.join(root, ".strom", "workers");
|
|
11
11
|
}
|
|
12
|
+
function pauseFile(root, id) {
|
|
13
|
+
return path.join(dir(root), `${id}.paused`);
|
|
14
|
+
}
|
|
15
|
+
/** A run waits for its gate (the app shows it paused, not at work) — or goes on (undefined). */
|
|
16
|
+
export function markPaused(root, id, paused) {
|
|
17
|
+
const file = pauseFile(root, id);
|
|
18
|
+
if (!paused)
|
|
19
|
+
return fs.rmSync(file, { force: true });
|
|
20
|
+
fs.mkdirSync(dir(root), { recursive: true });
|
|
21
|
+
fs.writeFileSync(file, JSON.stringify(paused));
|
|
22
|
+
}
|
|
23
|
+
function pausedOf(root, id) {
|
|
24
|
+
try {
|
|
25
|
+
const p = JSON.parse(fs.readFileSync(pauseFile(root, id), "utf8"));
|
|
26
|
+
return typeof p.until === "string" ? { until: p.until, ...(typeof p.reason === "string" ? { reason: p.reason } : {}) } : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
12
32
|
/** A worker's name, safe as a file name. */
|
|
13
33
|
export function isWorkerId(id) {
|
|
14
34
|
return !!id && /^[\w.-]{1,80}$/.test(id);
|
|
@@ -29,11 +49,16 @@ export function liveWorkers(root) {
|
|
|
29
49
|
}
|
|
30
50
|
for (const f of files) {
|
|
31
51
|
const file = path.join(dir(root), f);
|
|
52
|
+
const id = f.slice(0, -5);
|
|
32
53
|
const info = liveHolder(file, 7 * 24 * 3600_000);
|
|
33
|
-
if (info)
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
if (info) {
|
|
55
|
+
const paused = pausedOf(root, id);
|
|
56
|
+
out.push({ id, label: info.owner, since: info.at, ...(paused ? { paused } : {}) });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
36
59
|
fs.rmSync(file, { force: true });
|
|
60
|
+
fs.rmSync(pauseFile(root, id), { force: true });
|
|
61
|
+
}
|
|
37
62
|
}
|
|
38
63
|
return out;
|
|
39
64
|
}
|
package/dist/runners/claude.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
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
15
|
import { randomUUID } from "node:crypto";
|
|
16
|
-
import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
|
|
16
|
+
import { appendLog, looksLikeLimit, OWN_GROUP, spawnAgent, stopTree } from "./runner.js";
|
|
17
17
|
function describeTool(block) {
|
|
18
18
|
const input = block.input ?? {};
|
|
19
19
|
if (block.name === "Bash" && typeof input.command === "string")
|
|
@@ -71,6 +71,7 @@ function attempt(opts, args, input, timeoutMs) {
|
|
|
71
71
|
env: opts.interactive ? opts.env : headlessEnv(opts.env, opts.timeoutMs),
|
|
72
72
|
stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"],
|
|
73
73
|
windowsHide: !opts.interactive,
|
|
74
|
+
detached: !opts.interactive && OWN_GROUP,
|
|
74
75
|
});
|
|
75
76
|
// The brief goes in on stdin: no limit on its length on any platform.
|
|
76
77
|
if (!opts.interactive)
|
package/dist/runners/jsonl.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// recognised — the same for every such agent; each runner only reads its own
|
|
5
5
|
// events. Stopped at its time limit, an agent that can resume its session gets a
|
|
6
6
|
// few minutes to write down what it found (RunOptions.wrapUp).
|
|
7
|
-
import { appendLog, looksLikeLimit, spawnAgent, stopTree } from "./runner.js";
|
|
7
|
+
import { appendLog, looksLikeLimit, OWN_GROUP, spawnAgent, stopTree } from "./runner.js";
|
|
8
8
|
export async function runJsonLines(command, args, env, opts, resume, onEvent) {
|
|
9
9
|
const started = Date.now();
|
|
10
10
|
const heard = { metrics: {}, text: "", isError: false, denied: [] };
|
|
@@ -54,7 +54,7 @@ export async function runJsonLines(command, args, env, opts, resume, onEvent) {
|
|
|
54
54
|
/** One start of the agent; its events go to `heard`. */
|
|
55
55
|
function attempt(command, args, input, env, opts, timeoutMs, heard, onEvent) {
|
|
56
56
|
return new Promise((resolve) => {
|
|
57
|
-
const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
57
|
+
const child = spawnAgent(command, args, { cwd: opts.cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: OWN_GROUP });
|
|
58
58
|
child.stdin?.end(input);
|
|
59
59
|
let timedOut = false;
|
|
60
60
|
const timer = timeoutMs
|
package/dist/runners/runner.js
CHANGED
|
@@ -47,17 +47,30 @@ export function spawnAgent(command, args, opts) {
|
|
|
47
47
|
}
|
|
48
48
|
return spawn(found, args, { ...opts, env: opts.env });
|
|
49
49
|
}
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* A headless agent in a process group of its own (not on Windows, whose console sends Ctrl-C to every process of
|
|
52
|
+
* it): Ctrl-C in the terminal reaches strom alone, which asks the session to finish first (strom run).
|
|
53
|
+
*/
|
|
54
|
+
export const OWN_GROUP = process.platform !== "win32";
|
|
55
|
+
/** Stop a child and everything it started (Windows: the whole process tree; elsewhere its own group, when it has one). */
|
|
51
56
|
export function stopTree(child) {
|
|
52
57
|
if (child.exitCode !== null || child.pid === undefined)
|
|
53
58
|
return;
|
|
54
59
|
if (process.platform === "win32")
|
|
55
60
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
56
61
|
else {
|
|
57
|
-
|
|
62
|
+
const kill = (sig) => {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(-child.pid, sig); // its group: what it started too
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
child.kill(sig); // no group of its own
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
kill("SIGTERM");
|
|
58
71
|
setTimeout(() => {
|
|
59
72
|
if (child.exitCode === null)
|
|
60
|
-
|
|
73
|
+
kill("SIGKILL");
|
|
61
74
|
}, 5000).unref();
|
|
62
75
|
}
|
|
63
76
|
}
|
package/dist/runners/script.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (run with node); it gets the same environment an agent would, and the
|
|
4
4
|
// prompt both on stdin (like a headless agent) and in STROM_PROMPT.
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { appendLog, looksLikeLimit, stopTree } from "./runner.js";
|
|
6
|
+
import { appendLog, looksLikeLimit, OWN_GROUP, stopTree } from "./runner.js";
|
|
7
7
|
export const scriptRunner = {
|
|
8
8
|
id: "script",
|
|
9
9
|
command: process.execPath,
|
|
@@ -26,6 +26,7 @@ function once(opts, prompt, timeoutMs, wrapUp) {
|
|
|
26
26
|
cwd: opts.cwd,
|
|
27
27
|
env: { ...opts.env, STROM_PROMPT: prompt, ...(wrapUp ? { STROM_WRAP_UP: "1" } : {}) },
|
|
28
28
|
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
detached: OWN_GROUP,
|
|
29
30
|
});
|
|
30
31
|
child.stdin.end(prompt);
|
|
31
32
|
let timedOut = false;
|