strom-research 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/assets/lang/cs.json +164 -7
- package/assets/lang/de.json +164 -7
- package/assets/plugins/README.md +8 -1
- package/assets/plugins/gates/README.md +1 -1
- package/assets/plugins/hooks/README.md +91 -0
- package/dist/agents/files.js +142 -99
- package/dist/agents/global.js +83 -6
- package/dist/agents/launch.js +12 -5
- package/dist/agents/profiles.js +13 -2
- package/dist/cli/main.js +17 -1
- package/dist/cli/menu-parts.js +140 -0
- package/dist/cli/menu-research.js +194 -0
- package/dist/cli/menu-settings.js +273 -0
- package/dist/cli/menu-waiting.js +79 -0
- package/dist/cli/menu.js +91 -97
- package/dist/cli/registry.js +1 -1
- package/dist/cli/ui.js +162 -6
- package/dist/cli/wizard.js +48 -2
- package/dist/commands/browse.js +2 -2
- package/dist/commands/connectors.js +25 -2
- package/dist/commands/hooks.js +141 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/intake.js +2 -2
- package/dist/commands/meta.js +6 -2
- package/dist/commands/research.js +55 -16
- package/dist/commands/session.js +15 -7
- package/dist/commands/setup.js +51 -2
- package/dist/commands/start.js +9 -4
- package/dist/commands/tasks.js +41 -4
- package/dist/core/browser.js +50 -0
- package/dist/core/config.js +7 -1
- package/dist/core/hooks.js +210 -0
- package/dist/core/install.js +3 -2
- package/dist/core/media.js +21 -7
- package/dist/core/phrases.js +1 -0
- package/dist/core/relocate.js +111 -0
- package/dist/core/shortcut.js +1 -4
- package/dist/core/tree.js +19 -2
- package/dist/core/which.js +18 -0
- package/dist/runners/claude.js +3 -0
- package/dist/runners/codex.js +14 -5
- package/dist/runners/grok.js +108 -0
- package/dist/runners/index.js +2 -0
- package/dist/runners/opencode.js +8 -4
- package/package.json +1 -1
package/dist/commands/start.js
CHANGED
|
@@ -13,10 +13,11 @@ import { PROFILES } from "../agents/profiles.js";
|
|
|
13
13
|
import { conversationArgs } from "../agents/launch.js";
|
|
14
14
|
import { globalTargets, installGlobal, isInstalled, uninstallGlobal } from "../agents/global.js";
|
|
15
15
|
import { syncAgentFiles } from "../agents/files.js";
|
|
16
|
-
import { AGENTS, findAgent, isAgent } from "../core/which.js";
|
|
16
|
+
import { AGENTS, findAgent, isAgent, withoutAgentMarks } from "../core/which.js";
|
|
17
17
|
import { DESKTOP_APPS, agentsHere, whereToTalk } from "../core/apps.js";
|
|
18
18
|
import { assertIntact } from "../core/integrity.js";
|
|
19
19
|
import { treeBrowserConnectors } from "../core/connector.js";
|
|
20
|
+
import { browserNote } from "./connectors.js";
|
|
20
21
|
import { appOpensResearch, importAppUrl, installedStromApp, liveAppUrl, noticeStromApp, STROM_APP_URL, stromAppUrl } from "../core/stromapp.js";
|
|
21
22
|
import { liveRunning, serveLive, startLive, stopLive } from "../core/live.js";
|
|
22
23
|
import { openForUser } from "../core/open.js";
|
|
@@ -62,7 +63,7 @@ register({
|
|
|
62
63
|
"(agent.permissions: ask, auto, full), your model, and asks you what matters. Where you talk (agent.where): the\n" +
|
|
63
64
|
"agent's desktop app when it is installed (Claude, ChatGPT/Codex, OpenCode — the tree folder and the first message\n" +
|
|
64
65
|
"filled in; you confirm the folder and send it), else its CLI in this terminal (Claude Code, Codex, Antigravity,\n" +
|
|
65
|
-
"OpenCode). The first message is yours to give (--say); without it the agent reports where things\n" +
|
|
66
|
+
"OpenCode, Grok). The first message is yours to give (--say); without it the agent reports where things\n" +
|
|
66
67
|
"stand and suggests what next.",
|
|
67
68
|
options: [
|
|
68
69
|
{ name: "say", type: "string", value: "<text>", description: "your first message to the agent" },
|
|
@@ -123,6 +124,7 @@ register({
|
|
|
123
124
|
return { text: ui(lang, opened ? "ui.chat.handover" : "ui.chat.handover.failed", { agent: profile.name }), data: { agent, handover: opened ? "window" : "none", cwd: tree.root } };
|
|
124
125
|
}
|
|
125
126
|
const base = {
|
|
127
|
+
root: tree.root,
|
|
126
128
|
kickoff,
|
|
127
129
|
level,
|
|
128
130
|
model: ctx.settings.models(agent, tree.config).lead,
|
|
@@ -131,7 +133,7 @@ register({
|
|
|
131
133
|
// its research when there is one, the day.
|
|
132
134
|
name: ["Strom", tree.config.name, researches.length === 1 ? researches[0].name : undefined, new Date().toLocaleDateString(lang, { day: "numeric", month: "numeric" })].filter(Boolean).join(" · "),
|
|
133
135
|
shared,
|
|
134
|
-
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0 } : {}),
|
|
136
|
+
...(agent === "claude" ? { chrome: treeBrowserConnectors(tree, shared).length > 0, remote: ctx.settings.agentRemote() } : {}),
|
|
135
137
|
};
|
|
136
138
|
const args = conversationArgs(agent, base);
|
|
137
139
|
if (opts.print)
|
|
@@ -147,10 +149,13 @@ register({
|
|
|
147
149
|
const worker = `${agent}-${process.pid}-${Date.now().toString(36)}`;
|
|
148
150
|
const leave = enterWorker(tree.root, worker, `${profile.name} conversation`);
|
|
149
151
|
ctx.io.stdout(ui(lang, "ui.chat.open", { agent: profile.name, exit: profile.exit }) + "\n");
|
|
152
|
+
const browserSays = browserNote(ctx, tree, agent);
|
|
153
|
+
if (browserSays)
|
|
154
|
+
ctx.io.stdout(browserSays + "\n");
|
|
150
155
|
// Claude Code asks once whether the folder is to be trusted — its own safety step, kept; the user knows what to answer.
|
|
151
156
|
if (agent === "claude" && !claudeTrusts(tree.root, ctx.env))
|
|
152
157
|
ctx.io.stdout(ui(lang, "ui.chat.trust") + "\n");
|
|
153
|
-
const env = { ...prependPath(ctx.env, shimDir(tree)), STROM_WORKER: worker, ...(base.model ? { STROM_MODEL: base.model } : {}) };
|
|
158
|
+
const env = { ...prependPath(withoutAgentMarks(ctx.env), shimDir(tree)), STROM_WORKER: worker, ...(base.model ? { STROM_MODEL: base.model } : {}) };
|
|
154
159
|
delete env.STROM_HANDOVER;
|
|
155
160
|
let code;
|
|
156
161
|
try {
|
package/dist/commands/tasks.js
CHANGED
|
@@ -16,6 +16,8 @@ import { foldText } from "../core/text.js";
|
|
|
16
16
|
import { now, typeOfId } from "../core/tree.js";
|
|
17
17
|
import { resolveResearch } from "./research.js";
|
|
18
18
|
import { makeNote } from "../core/actions.js";
|
|
19
|
+
import { phrase } from "../core/phrases.js";
|
|
20
|
+
import { readJsonLines } from "../core/json.js";
|
|
19
21
|
import { lacksImages, rankTasks } from "../core/queue.js";
|
|
20
22
|
import { clipNote, inboxFolderFor, parseImageList, transcriptNote } from "../core/media.js";
|
|
21
23
|
function written(tree) {
|
|
@@ -82,6 +84,25 @@ export function rankedQueue(tree, filter = {}) {
|
|
|
82
84
|
function taskLine(t) {
|
|
83
85
|
return [t.id, `p${t.priority}`, t.level, t.state === "open" ? "" : `[${t.state}]`, truncate(t.what, 70)];
|
|
84
86
|
}
|
|
87
|
+
/** Why a task was put aside: on the task, or — parked by an older strom — in the history. */
|
|
88
|
+
export function parkedWhy(tree, t) {
|
|
89
|
+
if (t.state !== "parked")
|
|
90
|
+
return undefined;
|
|
91
|
+
if (t.parkedReason)
|
|
92
|
+
return t.parkedReason;
|
|
93
|
+
const dir = path.join(tree.dataDir, "ops");
|
|
94
|
+
let last;
|
|
95
|
+
try {
|
|
96
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".jsonl")))
|
|
97
|
+
for (const o of readJsonLines(path.join(dir, f)))
|
|
98
|
+
if (o.op === "task.park" && o.targets.includes(t.id) && (!last || o.at > last.at))
|
|
99
|
+
last = o;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// no history to read
|
|
103
|
+
}
|
|
104
|
+
return last?.reason;
|
|
105
|
+
}
|
|
85
106
|
function taskDetail(tree, t) {
|
|
86
107
|
const where = t.where.map((w) => {
|
|
87
108
|
const b = /^B\d{4,}$/.test(w) ? tree.get(w) : undefined;
|
|
@@ -89,7 +110,7 @@ function taskDetail(tree, t) {
|
|
|
89
110
|
});
|
|
90
111
|
const searches = tree.list("search").filter((s) => s.task === t.id || s.recordsets.some((b) => t.where.includes(b)));
|
|
91
112
|
const lessons = tree.list("lesson").filter((l) => l.target && t.where.includes(l.target));
|
|
92
|
-
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
113
|
+
return lines(`${t.id} ${t.what}`, `level ${t.level} · priority ${t.priority} · ${t.state}${t.research ? ` · research ${t.research}` : ""}${t.parkedUntil ? ` · parked until ${t.parkedUntil}` : ""}${t.waitingOn ? ` · waiting on ${t.waitingOn}` : ""}`, t.state === "parked" ? `parked ${parkedWhy(tree, t) ?? "(no reason recorded)"} — strom task wake ${t.id} to go on` : undefined, `where ${where.join("\n ")}`, `why ${t.why}`, `done ${t.doneWhen}`, t.subject.length ? `about ${t.subject.join(" ")}` : undefined, t.result ? `result ${t.result}${t.produced?.length ? ` → ${t.produced.join(" ")}` : ""}` : undefined, searches.length ? "\nalready searched there\n" + table(searches.map((s) => [` ${s.id}`, s.result, truncate(s.question, 60), s.scope.years ?? ""])) : undefined, lessons.length ? "\nlessons for these record sets\n" + lessons.map((l) => ` ${l.id} ${l.rule}`).join("\n") : undefined, ...t.notes.map((n) => `note ${n.text}`));
|
|
93
114
|
}
|
|
94
115
|
function stateChange(tree, ref, state, fields, reason, verb) {
|
|
95
116
|
const id = normId(ref, "task");
|
|
@@ -97,8 +118,12 @@ function stateChange(tree, ref, state, fields, reason, verb) {
|
|
|
97
118
|
if (t.state === "done" && state !== "open")
|
|
98
119
|
throw new UsageError(`${id} is already done`);
|
|
99
120
|
const next = { ...t, state, ...fields };
|
|
100
|
-
if (state !== "parked")
|
|
121
|
+
if (state !== "parked") {
|
|
101
122
|
delete next.parkedUntil;
|
|
123
|
+
delete next.parkedReason;
|
|
124
|
+
}
|
|
125
|
+
else if (reason)
|
|
126
|
+
next.parkedReason = reason;
|
|
102
127
|
if (state !== "waiting") {
|
|
103
128
|
delete next.waitingOn;
|
|
104
129
|
delete next.awaits;
|
|
@@ -401,10 +426,22 @@ register({
|
|
|
401
426
|
group: "tasks",
|
|
402
427
|
tree: true,
|
|
403
428
|
writes: true,
|
|
429
|
+
description: "--answer: what the user found or decided for a task that waited for them (a link, a book's number) — kept on\nthe task with what it asked, so the agent reads both in its brief.",
|
|
404
430
|
args: [{ name: "task", description: "task ID", required: true }],
|
|
405
|
-
|
|
431
|
+
options: [{ name: "answer", type: "string", value: "<text>", description: "the user's answer to what the task waited for" }],
|
|
432
|
+
examples: ["strom task wake T0002", 'strom task wake T0003 --answer "https://archive.example.org/book/5359"'],
|
|
433
|
+
run(ctx, { args, opts }) {
|
|
406
434
|
const tree = ctx.tree();
|
|
407
|
-
const
|
|
435
|
+
const id = normId(args[0], "task");
|
|
436
|
+
const answer = typeof opts.answer === "string" && opts.answer.trim() ? opts.answer.trim() : undefined;
|
|
437
|
+
const t = tree.withTreeLock(() => {
|
|
438
|
+
const before = requireRecord(tree, id, "task");
|
|
439
|
+
// an answer is to what the task waits for: one that waits no more (done, dropped, taken up) is left as it is
|
|
440
|
+
if (answer && before.state !== "waiting")
|
|
441
|
+
throw new UsageError(`${id} does not wait for the user (it is ${before.state}) — the answer is not needed there`, { hint: `strom task show ${id}` });
|
|
442
|
+
const note = answer ? makeNote(tree, phrase(tree.lang, "task.answer", { asked: truncate(before.waitingOn || before.what, 120), answer })) : undefined;
|
|
443
|
+
return stateChange(tree, id, "open", note ? { notes: [...before.notes, note] } : {}, undefined, "wake");
|
|
444
|
+
});
|
|
408
445
|
return { text: written(tree), data: { task: t } };
|
|
409
446
|
},
|
|
410
447
|
}, {
|
package/dist/core/browser.js
CHANGED
|
@@ -41,6 +41,56 @@ export const CHROME_ALLOW = [
|
|
|
41
41
|
export const CHROME_DENY = ["file_upload", "upload_image", "shortcuts_execute", "gif_creator", "browser_batch"].map((t) => `mcp__claude-in-chrome__${t}`);
|
|
42
42
|
/** The permission rule of Claude in Chrome for one site. */
|
|
43
43
|
export const chromeDomain = (host) => `ClaudeInChromeDomain(${host.replace(/^\*\./, "").toLowerCase()})`;
|
|
44
|
+
/** Claude in Chrome in the Chrome Web Store: the extension Claude Code's browser tools work through. */
|
|
45
|
+
export const CLAUDE_IN_CHROME_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
|
|
46
|
+
export const CLAUDE_IN_CHROME_URL = `https://chromewebstore.google.com/detail/${CLAUDE_IN_CHROME_ID}`;
|
|
47
|
+
/** The folders of the browsers' profiles (Chrome, Edge, Brave take extensions from the Chrome Web Store). */
|
|
48
|
+
function browserDataDirs(env, platform) {
|
|
49
|
+
const home = userHome(env);
|
|
50
|
+
if (platform === "darwin") {
|
|
51
|
+
const base = path.join(home, "Library", "Application Support");
|
|
52
|
+
return [
|
|
53
|
+
{ browser: "Chrome", dir: path.join(base, "Google", "Chrome") },
|
|
54
|
+
{ browser: "Edge", dir: path.join(base, "Microsoft Edge") },
|
|
55
|
+
{ browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser") },
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
if (platform === "win32") {
|
|
59
|
+
const base = env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
|
|
60
|
+
return [
|
|
61
|
+
{ browser: "Chrome", dir: path.join(base, "Google", "Chrome", "User Data") },
|
|
62
|
+
{ browser: "Edge", dir: path.join(base, "Microsoft", "Edge", "User Data") },
|
|
63
|
+
{ browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser", "User Data") },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
const base = env.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
67
|
+
return [
|
|
68
|
+
{ browser: "Chrome", dir: path.join(base, "google-chrome") },
|
|
69
|
+
{ browser: "Edge", dir: path.join(base, "microsoft-edge") },
|
|
70
|
+
{ browser: "Brave", dir: path.join(base, "BraveSoftware", "Brave-Browser") },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Is Claude in Chrome installed — in which browsers? Only the extension's folder in the profiles is looked for (its
|
|
75
|
+
* name), nothing else of the browser is read. Whether it is signed in and connected strom cannot see.
|
|
76
|
+
*/
|
|
77
|
+
export function claudeInChrome(env, platform = process.platform) {
|
|
78
|
+
const browsers = [];
|
|
79
|
+
const extension = [];
|
|
80
|
+
for (const { browser, dir } of browserDataDirs(env, platform)) {
|
|
81
|
+
let profiles;
|
|
82
|
+
try {
|
|
83
|
+
profiles = fs.readdirSync(dir).filter((n) => n === "Default" || /^Profile \d+$/u.test(n));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
browsers.push(browser);
|
|
89
|
+
if (profiles.some((p) => fs.existsSync(path.join(dir, p, "Extensions", CLAUDE_IN_CHROME_ID))))
|
|
90
|
+
extension.push(browser);
|
|
91
|
+
}
|
|
92
|
+
return { browsers, extension };
|
|
93
|
+
}
|
|
44
94
|
/** The folder a browser saves downloads into by default: ~/Downloads, or what the desktop names it (Linux). */
|
|
45
95
|
export function downloadsDir(env, platform = process.platform) {
|
|
46
96
|
const home = userHome(env);
|
package/dist/core/config.js
CHANGED
|
@@ -24,7 +24,7 @@ export const SETTINGS = [
|
|
|
24
24
|
{ key: "shared", env: "STROM_SHARED", tree: false, kind: "path", description: "shared data: scans, catalog, tools (default <home>/shared)" },
|
|
25
25
|
{ key: "trees", env: "STROM_TREES", tree: false, kind: "path", description: "folder holding the trees (default <home>)" },
|
|
26
26
|
{ key: "lang", env: "STROM_LANG", tree: true, kind: "lang", description: "research language: the agent talks and writes in it" },
|
|
27
|
-
{ key: "agent", env: "STROM_AGENT", tree: true, kind: "agent", description: "AI agent doing the research: claude, codex, antigravity, opencode" },
|
|
27
|
+
{ key: "agent", env: "STROM_AGENT", tree: true, kind: "agent", description: "AI agent doing the research: claude, codex, antigravity, opencode, grok" },
|
|
28
28
|
...TIERS.map((t) => ({
|
|
29
29
|
key: `model.${t}`,
|
|
30
30
|
env: t === "lead" ? "STROM_MODEL" : `STROM_MODEL_${t.toUpperCase()}`,
|
|
@@ -54,6 +54,7 @@ export const SETTINGS = [
|
|
|
54
54
|
// Read from the config file only — no variable, no flag, no tree: nothing an agent can set.
|
|
55
55
|
{ key: "agent.permissions", env: "", tree: false, kind: "choice", choices: PERMISSION_LEVELS, description: "what the agent may do without asking you: ask (what the tree allows; anything else it asks), auto (default: what the tree allows; anything else the agent's own review decides, asking only when risky), full (everything but what the tree denies) — only you raise it" },
|
|
56
56
|
{ key: "agent.where", env: "STROM_AGENT_WHERE", tree: false, kind: "choice", choices: ["app", "terminal"], description: "where you talk with the agent: app (its desktop app — the easiest), terminal (its CLI) — unset: the app when it is installed" },
|
|
57
|
+
{ key: "agent.remote", env: "", tree: false, kind: "choice", choices: ["on", "off"], description: "the Claude Code sessions strom starts (strom run, strom chat in the terminal) with Remote Control: on — follow and steer them from claude.ai or the Claude app on your phone; off (default) — only you turn it on" },
|
|
57
58
|
{ key: "updates", env: "STROM_UPDATES", tree: false, kind: "choice", choices: ["check", "off"], description: "look for new versions of strom: check (default — at most once a day, one small file from the project's releases; strom says so, strom update installs it) or off" },
|
|
58
59
|
{ key: "strom.app", env: "", tree: false, kind: "choice", choices: ["yes", "no"], description: "you use the Strom app: yes (strom says which file to import into it), no (strom never mentions it) — unset: strom notices it itself" },
|
|
59
60
|
{ key: "strom.app.url", env: "STROM_APP_URL", tree: false, kind: "url", description: "another copy of the Strom app to open instead of https://stromapp.info/run/ — its beta (https://beta.stromapp.info/run/), its development (http://127.0.0.1:8080/); installed from a browser, that copy opens as its own app" },
|
|
@@ -69,6 +70,7 @@ const FIELDS = {
|
|
|
69
70
|
"browser.downloads": "browserDownloads",
|
|
70
71
|
"agent.permissions": "agentPermissions",
|
|
71
72
|
"agent.where": "agentWhere",
|
|
73
|
+
"agent.remote": "agentRemote",
|
|
72
74
|
"strom.app": "stromApp",
|
|
73
75
|
"strom.app.url": "stromAppUrl",
|
|
74
76
|
"main.person": "mainPerson",
|
|
@@ -334,6 +336,10 @@ export class Settings {
|
|
|
334
336
|
downloads() {
|
|
335
337
|
return String(this.resolve("browser.downloads")?.value ?? downloadsDir(this.env));
|
|
336
338
|
}
|
|
339
|
+
/** Claude Code sessions strom starts with Remote Control (agent.remote: on). */
|
|
340
|
+
agentRemote() {
|
|
341
|
+
return this.resolve("agent.remote")?.value === "on";
|
|
342
|
+
}
|
|
337
343
|
/** Where the user wants to talk with the agent, if they said (env, else the config); see whereToTalk. */
|
|
338
344
|
agentWhere() {
|
|
339
345
|
const r = this.resolve("agent.where");
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Hooks: the user's programs told of what was saved into a research. After
|
|
2
|
+
// every commit of a research, strom starts each hook the user turned on
|
|
3
|
+
// (`strom hook on <name>`) in the background — never waiting for it, never
|
|
4
|
+
// failing because of it — with the operations that commit saved: a person
|
|
5
|
+
// added, a fact, a task done, a session closed… A hook filters them
|
|
6
|
+
// (`events` in hook.json, or itself), may read more through strom (`strom
|
|
7
|
+
// person card P0002`) and does with it what the user wants: a message to
|
|
8
|
+
// their phone, a log, a backup.
|
|
9
|
+
//
|
|
10
|
+
// A hook is a folder in the plugins folder, <shared>/plugins/hooks/<name>/,
|
|
11
|
+
// with hook.json ({"interface": 1, "command": [...], "events": [...]}) and its
|
|
12
|
+
// program (interface: assets/plugins/hooks/README.md, copied next to the
|
|
13
|
+
// hooks). The event comes as one JSON document on the program's stdin; what it
|
|
14
|
+
// prints goes to hook.log in its folder. A hook runs only when the user turned
|
|
15
|
+
// it on — a folder an agent put there does nothing by itself.
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { StromError, UsageError } from "./errors.js";
|
|
20
|
+
import { NAME_RE, pluginsDir } from "./connector.js";
|
|
21
|
+
import { readAsset } from "./assets.js";
|
|
22
|
+
export const HOOK_INTERFACE = 1;
|
|
23
|
+
const MANIFEST = "hook.json";
|
|
24
|
+
/** How long `strom hook test` waits for a hook. */
|
|
25
|
+
const DEFAULT_TIMEOUT_S = 60;
|
|
26
|
+
/** Event files older than this are cleared away (a hook reads its own at once). */
|
|
27
|
+
const KEEP_MS = 24 * 3600_000;
|
|
28
|
+
/** The hook's output, appended; cut to its last part when it grows past this. */
|
|
29
|
+
const LOG_MAX = 1024 * 1024;
|
|
30
|
+
export function hooksDir(shared) {
|
|
31
|
+
return path.join(pluginsDir(shared), "hooks");
|
|
32
|
+
}
|
|
33
|
+
/** The hooks folder with its interface (refreshed). */
|
|
34
|
+
export function ensureHooksDir(shared) {
|
|
35
|
+
const dir = hooksDir(shared);
|
|
36
|
+
try {
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
const text = readAsset("plugins", "hooks", "README.md");
|
|
39
|
+
const file = path.join(dir, "README.md");
|
|
40
|
+
let old;
|
|
41
|
+
try {
|
|
42
|
+
old = fs.readFileSync(file, "utf8");
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
old = undefined;
|
|
46
|
+
}
|
|
47
|
+
if (text !== undefined && old !== text)
|
|
48
|
+
fs.writeFileSync(file, text);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (!["EACCES", "EPERM", "EROFS"].includes(err?.code ?? ""))
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
export function listHooks(shared) {
|
|
57
|
+
let names = [];
|
|
58
|
+
try {
|
|
59
|
+
names = fs
|
|
60
|
+
.readdirSync(hooksDir(shared), { withFileTypes: true })
|
|
61
|
+
.filter((d) => d.isDirectory() && NAME_RE.test(d.name))
|
|
62
|
+
.map((d) => d.name);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const name of names.sort()) {
|
|
69
|
+
try {
|
|
70
|
+
out.push(loadHook(shared, name));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// not a hook (yet): `strom hook test <name>` says why
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
export function loadHook(shared, name) {
|
|
79
|
+
if (!NAME_RE.test(name))
|
|
80
|
+
throw new UsageError(`invalid hook name "${name}"`, { hint: "lowercase letters, digits and dashes, e.g. telegram" });
|
|
81
|
+
const dir = path.join(hooksDir(shared), name);
|
|
82
|
+
const file = path.join(dir, MANIFEST);
|
|
83
|
+
if (!fs.existsSync(file))
|
|
84
|
+
throw new StromError(`no hook "${name}" (no ${file})`, { hint: `the hooks here: strom hook list — a hook is a folder in ${hooksDir(shared)} with ${MANIFEST}` });
|
|
85
|
+
let m;
|
|
86
|
+
try {
|
|
87
|
+
m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw new StromError(`hook "${name}": ${MANIFEST} is not valid JSON (${err.message})`);
|
|
91
|
+
}
|
|
92
|
+
if (m.interface !== HOOK_INTERFACE)
|
|
93
|
+
throw new StromError(`hook "${name}" is for interface ${m.interface}, this strom knows ${HOOK_INTERFACE}`, {
|
|
94
|
+
hint: m.interface > HOOK_INTERFACE ? "update strom: strom update" : `see ${path.join(hooksDir(shared), "README.md")}`,
|
|
95
|
+
});
|
|
96
|
+
if (!Array.isArray(m.command) || !m.command.length || !m.command.every((c) => typeof c === "string" && c))
|
|
97
|
+
throw new StromError(`hook "${name}": "command" must be a list of strings, e.g. ["node", "hook.ts"]`);
|
|
98
|
+
if (m.events !== undefined && (!Array.isArray(m.events) || !m.events.every((e) => typeof e === "string" && e)))
|
|
99
|
+
throw new StromError(`hook "${name}": "events" must be a list like ["person.add", "session.*"]`);
|
|
100
|
+
return { name, dir, manifest: m };
|
|
101
|
+
}
|
|
102
|
+
/** Does the hook want this operation? "*" all, "person.*" a kind, "person.add" one. */
|
|
103
|
+
export function wants(m, op) {
|
|
104
|
+
return (m.events ?? ["*"]).some((p) => p === "*" || p === op || (p.endsWith(".*") && op.startsWith(p.slice(0, -1))));
|
|
105
|
+
}
|
|
106
|
+
/** The operations of a commit the hook wants, or none. */
|
|
107
|
+
export function forHook(hook, events) {
|
|
108
|
+
return events.filter((e) => wants(hook.manifest, e.op));
|
|
109
|
+
}
|
|
110
|
+
function program(hook) {
|
|
111
|
+
const [cmd, ...args] = hook.manifest.command;
|
|
112
|
+
return { cmd: cmd === "node" ? process.execPath : cmd, args };
|
|
113
|
+
}
|
|
114
|
+
function hookEnv(env, hook, payload, file) {
|
|
115
|
+
return {
|
|
116
|
+
...env,
|
|
117
|
+
STROM_HOOK: hook.name,
|
|
118
|
+
STROM_TREE: payload.tree.root,
|
|
119
|
+
STROM_LANG: payload.tree.lang,
|
|
120
|
+
STROM_EVENT_FILE: file,
|
|
121
|
+
// what it runs of strom reads the research the event came from, as the user would
|
|
122
|
+
STROM_NONINTERACTIVE: "1",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** The event as a file (the hook's stdin, and STROM_EVENT_FILE); old ones are cleared away. */
|
|
126
|
+
function eventFile(hook, payload) {
|
|
127
|
+
const dir = path.join(hook.dir, ".events");
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
try {
|
|
130
|
+
for (const f of fs.readdirSync(dir)) {
|
|
131
|
+
const p = path.join(dir, f);
|
|
132
|
+
if (Date.now() - fs.statSync(p).mtimeMs > KEEP_MS)
|
|
133
|
+
fs.rmSync(p, { force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// another process cleared it
|
|
138
|
+
}
|
|
139
|
+
const file = path.join(dir, `${payload.at.replace(/[:.]/g, "-")}-${process.pid}-${Math.random().toString(36).slice(2, 8)}.json`);
|
|
140
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n");
|
|
141
|
+
return file;
|
|
142
|
+
}
|
|
143
|
+
/** Its log, cut to its last part when it has grown too big. */
|
|
144
|
+
function logFd(hook) {
|
|
145
|
+
const file = path.join(hook.dir, "hook.log");
|
|
146
|
+
try {
|
|
147
|
+
if (fs.statSync(file).size > LOG_MAX) {
|
|
148
|
+
const tail = fs.readFileSync(file).subarray(-LOG_MAX / 2);
|
|
149
|
+
fs.writeFileSync(file, tail);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// no log yet
|
|
154
|
+
}
|
|
155
|
+
return fs.openSync(file, "a");
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Tell the hooks the user turned on what a commit saved — in the background: strom neither waits for them nor
|
|
159
|
+
* fails because of them. A hook's own writes (it may run strom) tell no hook again.
|
|
160
|
+
*/
|
|
161
|
+
export function fireHooks(shared, names, env, payload) {
|
|
162
|
+
if (!shared || !names?.length || !payload.events.length || env.STROM_HOOK)
|
|
163
|
+
return;
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
try {
|
|
166
|
+
const hook = loadHook(shared, name);
|
|
167
|
+
const events = forHook(hook, payload.events);
|
|
168
|
+
if (!events.length)
|
|
169
|
+
continue;
|
|
170
|
+
const full = { ...payload, hook: name, events };
|
|
171
|
+
const file = eventFile(hook, full);
|
|
172
|
+
let input;
|
|
173
|
+
let log;
|
|
174
|
+
try {
|
|
175
|
+
input = fs.openSync(file, "r");
|
|
176
|
+
log = logFd(hook);
|
|
177
|
+
const { cmd, args } = program(hook);
|
|
178
|
+
const child = spawn(cmd, args, { cwd: hook.dir, env: hookEnv(env, hook, full, file), stdio: [input, log, log], detached: true, windowsHide: true });
|
|
179
|
+
child.on("error", () => undefined);
|
|
180
|
+
child.unref();
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
// the child has its own copies; ours are closed whatever happened
|
|
184
|
+
for (const fd of [input, log])
|
|
185
|
+
if (fd !== undefined)
|
|
186
|
+
fs.closeSync(fd);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// a hook that is gone or broken never stops the research: strom hook test <name> says why
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Run a hook now and wait for it (strom hook test): what it printed and how it ended. */
|
|
195
|
+
export function runHookNow(hook, env, payload) {
|
|
196
|
+
const file = eventFile(hook, payload);
|
|
197
|
+
const { cmd, args } = program(hook);
|
|
198
|
+
const r = spawnSync(cmd, args, {
|
|
199
|
+
cwd: hook.dir,
|
|
200
|
+
env: hookEnv(env, hook, payload, file),
|
|
201
|
+
input: fs.readFileSync(file),
|
|
202
|
+
encoding: "utf8",
|
|
203
|
+
timeout: (hook.manifest.timeout ?? DEFAULT_TIMEOUT_S) * 1000,
|
|
204
|
+
windowsHide: true,
|
|
205
|
+
});
|
|
206
|
+
const output = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
|
|
207
|
+
if (r.error)
|
|
208
|
+
return { status: null, output, error: r.error.code === "ETIMEDOUT" ? `no end within ${hook.manifest.timeout ?? DEFAULT_TIMEOUT_S} s` : r.error.message };
|
|
209
|
+
return { status: r.status, output };
|
|
210
|
+
}
|
package/dist/core/install.js
CHANGED
|
@@ -22,7 +22,8 @@ export function runInstaller(inst, env) {
|
|
|
22
22
|
*/
|
|
23
23
|
export function agentBinDirs(env, platform = process.platform) {
|
|
24
24
|
const home = userHome(env);
|
|
25
|
+
// Grok Build's installer: ~/.grok/bin (on PATH through the shell's profile; Windows: added by hand)
|
|
25
26
|
if (platform === "win32")
|
|
26
|
-
return [path.join(home, ".local", "bin"), path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "npm")];
|
|
27
|
-
return [path.join(home, ".local", "bin"), path.join(home, ".claude", "local")];
|
|
27
|
+
return [path.join(home, ".local", "bin"), path.join(env.APPDATA ?? path.join(home, "AppData", "Roaming"), "npm"), path.join(home, ".grok", "bin")];
|
|
28
|
+
return [path.join(home, ".local", "bin"), path.join(home, ".claude", "local"), path.join(home, ".grok", "bin")];
|
|
28
29
|
}
|
package/dist/core/media.js
CHANGED
|
@@ -64,22 +64,36 @@ const MIME = {
|
|
|
64
64
|
export function mimeOf(file) {
|
|
65
65
|
return MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
66
66
|
}
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Every file under the given paths (folders recursively), hidden files skipped. What is inside a folder but cannot be
|
|
69
|
+
* read (a broken link or alias, a folder the system keeps to itself) is left out; the paths given must be there.
|
|
70
|
+
*/
|
|
68
71
|
export function collectFiles(paths) {
|
|
69
72
|
const out = [];
|
|
70
|
-
const walk = (p) => {
|
|
73
|
+
const walk = (p, given) => {
|
|
71
74
|
const base = path.basename(p);
|
|
72
|
-
if (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini")
|
|
75
|
+
if (!given && (base.startsWith(".") || base === "Thumbs.db" || base === "desktop.ini"))
|
|
76
|
+
return;
|
|
77
|
+
let st;
|
|
78
|
+
let names = [];
|
|
79
|
+
try {
|
|
80
|
+
st = fs.statSync(p);
|
|
81
|
+
if (st.isDirectory())
|
|
82
|
+
names = fs.readdirSync(p).sort();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (given)
|
|
86
|
+
throw err;
|
|
73
87
|
return;
|
|
74
|
-
|
|
88
|
+
}
|
|
75
89
|
if (st.isDirectory())
|
|
76
|
-
for (const e of
|
|
77
|
-
walk(path.join(p, e));
|
|
90
|
+
for (const e of names)
|
|
91
|
+
walk(path.join(p, e), false);
|
|
78
92
|
else if (st.isFile())
|
|
79
93
|
out.push(p);
|
|
80
94
|
};
|
|
81
95
|
for (const p of paths)
|
|
82
|
-
walk(p);
|
|
96
|
+
walk(p, true);
|
|
83
97
|
return out;
|
|
84
98
|
}
|
|
85
99
|
const digitGroups = (file) => path.basename(file, path.extname(file)).match(/\d+/g) ?? [];
|
package/dist/core/phrases.js
CHANGED
|
@@ -76,6 +76,7 @@ export const PHRASES = {
|
|
|
76
76
|
"intake.done.imported": "the leads are checked for obvious mistakes (duplicates, impossible dates) and the first locate/link tasks exist",
|
|
77
77
|
"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 …)",
|
|
78
78
|
"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 …)",
|
|
79
|
+
"task.answer": "The user answered (to: {asked}): {answer}",
|
|
79
80
|
};
|
|
80
81
|
const catalogs = new Map();
|
|
81
82
|
/**
|
|
@@ -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/shortcut.js
CHANGED
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { configDir, desktopDir, userHome } from "./paths.js";
|
|
9
9
|
import { stromLauncher } from "./self.js";
|
|
10
|
+
import { isAgentMark } from "./which.js";
|
|
10
11
|
import { assetPath } from "./assets.js";
|
|
11
12
|
/** Strom Research's icon, as each system takes it. */
|
|
12
13
|
export const ICON = { png: assetPath("icon", "icon-512.png"), ico: assetPath("icon", "icon.ico") };
|
|
@@ -96,10 +97,6 @@ export function createShortcut(name, env, platform = process.platform) {
|
|
|
96
97
|
*/
|
|
97
98
|
/** One handover window at a time: another asked within this long is not opened. */
|
|
98
99
|
export const HANDOVER_GAP_MS = 15_000;
|
|
99
|
-
/** What says an agent runs strom (core/which.ts detectAgent) — not the user's own settings of an agent (…_HOME). */
|
|
100
|
-
function isAgentMark(k) {
|
|
101
|
-
return ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "AI_AGENT", "OPENCODE", "OPENCODE_PID"].includes(k) || (/^(CODEX|ANTIGRAVITY)_/.test(k) && !/_HOME$/.test(k));
|
|
102
|
-
}
|
|
103
100
|
export function openInNewTerminal(argv, cwd, env, platform = process.platform) {
|
|
104
101
|
if (env.STROM_NO_OPEN === "1")
|
|
105
102
|
return false;
|