@bike4mind/cli 0.18.5 → 0.20.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/LICENSE +1 -1
- package/README.md +204 -35
- package/bin/bike4mind-cli.mjs +137 -24
- package/bin/hearth-hook.mjs +292 -0
- package/dist/AgentHistoryStore-C8uUKjjC.mjs +35512 -0
- package/dist/ApiClient-B_CQrUiF.mjs +277 -0
- package/dist/{ConfigStore-D39UqFnY.mjs → ConfigStore-DD3DcC3-.mjs} +6199 -3954
- package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
- package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
- package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
- package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
- package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
- package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
- package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
- package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
- package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
- package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
- package/dist/buildAgent-mVuXU_H4.mjs +824 -0
- package/dist/commands/acpCommand.mjs +798 -0
- package/dist/commands/apiCommand.mjs +14 -16
- package/dist/commands/doctorCommand.mjs +5 -5
- package/dist/commands/envCommand.mjs +1 -1
- package/dist/commands/headlessCommand.mjs +272 -76
- package/dist/commands/mcpCommand.mjs +14 -1
- package/dist/commands/pluginCommand.mjs +232 -0
- package/dist/commands/updateCommand.mjs +10 -9
- package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
- package/dist/index.mjs +3281 -2322
- package/dist/{package-I_v_WFUn.mjs → package-BqKSCbso.mjs} +1 -1
- package/dist/serve-CuF0I5en.mjs +772 -0
- package/dist/store-BG3e54c8.mjs +3 -0
- package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
- package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
- package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
- package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
- package/package.json +48 -43
- package/dist/BackgroundAgentManager-D-xsWd3C.mjs +0 -27303
- package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
- package/dist/store-DgzCTRkN.mjs +0 -3
- package/dist/utils-Cdktpk_k.mjs +0 -158
- package/dist/utils-DEizxshI.mjs +0 -3
|
@@ -24,10 +24,10 @@ var ImageStore = class ImageStore {
|
|
|
24
24
|
this.RETENTION_DAYS = 7;
|
|
25
25
|
}
|
|
26
26
|
static {
|
|
27
|
-
this.MAX_RAW_SIZE =
|
|
27
|
+
this.MAX_RAW_SIZE = 768e3;
|
|
28
28
|
}
|
|
29
29
|
static {
|
|
30
|
-
this.MAX_PRECOMPRESS_SIZE =
|
|
30
|
+
this.MAX_PRECOMPRESS_SIZE = 52428800;
|
|
31
31
|
}
|
|
32
32
|
constructor() {
|
|
33
33
|
this.ensureDirectories();
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises } from "fs";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { z as z$1 } from "zod";
|
|
6
|
+
//#region src/plugins/PluginStore.ts
|
|
7
|
+
/**
|
|
8
|
+
* Discovery for externally-installed CLI plugins.
|
|
9
|
+
*
|
|
10
|
+
* Plugins are npm packages installed under ~/.bike4mind/plugins (via
|
|
11
|
+
* `b4m plugin add`, which runs `npm install --prefix` there) that declare a
|
|
12
|
+
* `b4m-plugin` field in their package.json:
|
|
13
|
+
*
|
|
14
|
+
* { "b4m-plugin": { "entry": "./dist/index.js", "configKey": "my-plugin" } }
|
|
15
|
+
*
|
|
16
|
+
* This module is read-only: it enumerates installed packages and validates
|
|
17
|
+
* manifests, producing descriptors the loader (src/features/loadPlugin.ts),
|
|
18
|
+
* `b4m plugin list`, and the /config editor all consume. It never imports
|
|
19
|
+
* plugin code and never throws from discover() - a broken package becomes an
|
|
20
|
+
* invalid descriptor with a reason.
|
|
21
|
+
*/
|
|
22
|
+
/** Feature keys owned by built-in modules; plugins may not claim them. */
|
|
23
|
+
const RESERVED_BUILTIN_KEYS = ["tavern"];
|
|
24
|
+
const PROTOTYPE_POLLUTING_KEYS = /* @__PURE__ */ new Set([
|
|
25
|
+
"__proto__",
|
|
26
|
+
"prototype",
|
|
27
|
+
"constructor",
|
|
28
|
+
"hasOwnProperty",
|
|
29
|
+
"isPrototypeOf",
|
|
30
|
+
"propertyIsEnumerable",
|
|
31
|
+
"toLocaleString",
|
|
32
|
+
"toString",
|
|
33
|
+
"valueOf",
|
|
34
|
+
"__defineGetter__",
|
|
35
|
+
"__defineSetter__",
|
|
36
|
+
"__lookupGetter__",
|
|
37
|
+
"__lookupSetter__"
|
|
38
|
+
]);
|
|
39
|
+
const B4mPluginManifestSchema = z$1.object({
|
|
40
|
+
entry: z$1.string().min(1),
|
|
41
|
+
configKey: z$1.string().min(1).optional()
|
|
42
|
+
});
|
|
43
|
+
const PluginPackageJsonSchema = z$1.looseObject({
|
|
44
|
+
name: z$1.string().min(1),
|
|
45
|
+
version: z$1.string().optional(),
|
|
46
|
+
description: z$1.string().optional(),
|
|
47
|
+
"b4m-plugin": B4mPluginManifestSchema.optional()
|
|
48
|
+
});
|
|
49
|
+
function getDefaultPluginsDir() {
|
|
50
|
+
return path.join(homedir(), ".bike4mind", "plugins");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A plugin is enabled only by an OWN features key set to exactly true. The
|
|
54
|
+
* own-property + strict check guards against a configKey that names an
|
|
55
|
+
* Object.prototype member (e.g. 'constructor') reading back truthy through the
|
|
56
|
+
* prototype chain. Single source of truth for the loader gate, the config
|
|
57
|
+
* editor toggle, and `b4m plugin list`.
|
|
58
|
+
*/
|
|
59
|
+
function isFeatureEnabled(features, configKey) {
|
|
60
|
+
if (!features || !Object.prototype.hasOwnProperty.call(features, configKey)) return false;
|
|
61
|
+
return features[configKey] === true;
|
|
62
|
+
}
|
|
63
|
+
function formatZodIssues(error) {
|
|
64
|
+
return error.issues.map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message).join("; ");
|
|
65
|
+
}
|
|
66
|
+
var PluginStore = class {
|
|
67
|
+
constructor(options) {
|
|
68
|
+
this.pluginsDir = options?.pluginsDir ?? getDefaultPluginsDir();
|
|
69
|
+
}
|
|
70
|
+
getPluginsDir() {
|
|
71
|
+
return this.pluginsDir;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Scan the plugins tree and return a descriptor per plugin package, valid or
|
|
75
|
+
* not. Packages without a b4m-plugin field (a plugin's own transitive deps
|
|
76
|
+
* land in the same node_modules) are skipped silently. First-wins dedup by
|
|
77
|
+
* package name across roots and by configKey across plugins.
|
|
78
|
+
*/
|
|
79
|
+
async discover() {
|
|
80
|
+
const packageDirs = [];
|
|
81
|
+
for (const root of this.candidateRoots()) packageDirs.push(...await this.enumeratePackageDirs(root));
|
|
82
|
+
const parsed = await Promise.all(packageDirs.map((dir) => this.parseManifest(dir)));
|
|
83
|
+
const descriptors = [];
|
|
84
|
+
const seenPackages = /* @__PURE__ */ new Set();
|
|
85
|
+
for (const descriptor of parsed) {
|
|
86
|
+
if (!descriptor || seenPackages.has(descriptor.name)) continue;
|
|
87
|
+
seenPackages.add(descriptor.name);
|
|
88
|
+
descriptors.push(descriptor);
|
|
89
|
+
}
|
|
90
|
+
return this.dedupeConfigKeys(descriptors);
|
|
91
|
+
}
|
|
92
|
+
getValid(descriptors) {
|
|
93
|
+
return descriptors.filter((d) => d.valid);
|
|
94
|
+
}
|
|
95
|
+
getInvalid(descriptors) {
|
|
96
|
+
return descriptors.filter((d) => !d.valid);
|
|
97
|
+
}
|
|
98
|
+
/** node_modules is what `npm install --prefix` produces; lib/node_modules covers a `-g --prefix` install. */
|
|
99
|
+
candidateRoots() {
|
|
100
|
+
return [path.join(this.pluginsDir, "node_modules"), path.join(this.pluginsDir, "lib", "node_modules")];
|
|
101
|
+
}
|
|
102
|
+
async enumeratePackageDirs(root) {
|
|
103
|
+
const dirs = [];
|
|
104
|
+
for (const entry of await this.readDirEntries(root)) {
|
|
105
|
+
if (entry.startsWith(".")) continue;
|
|
106
|
+
if (entry.startsWith("@")) {
|
|
107
|
+
for (const scoped of await this.readDirEntries(path.join(root, entry))) if (!scoped.startsWith(".")) dirs.push(path.join(root, entry, scoped));
|
|
108
|
+
} else dirs.push(path.join(root, entry));
|
|
109
|
+
}
|
|
110
|
+
return dirs;
|
|
111
|
+
}
|
|
112
|
+
async readDirEntries(dir) {
|
|
113
|
+
try {
|
|
114
|
+
return (await promises.readdir(dir, { withFileTypes: true })).filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name);
|
|
115
|
+
} catch {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Returns null for "not a plugin package" (skipped), a descriptor otherwise. */
|
|
120
|
+
async parseManifest(packageDir) {
|
|
121
|
+
const parent = path.basename(path.dirname(packageDir));
|
|
122
|
+
const dirName = parent.startsWith("@") ? `${parent}/${path.basename(packageDir)}` : path.basename(packageDir);
|
|
123
|
+
let rawJson;
|
|
124
|
+
try {
|
|
125
|
+
rawJson = await promises.readFile(path.join(packageDir, "package.json"), "utf-8");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error.code === "ENOENT") return null;
|
|
128
|
+
return {
|
|
129
|
+
valid: false,
|
|
130
|
+
name: dirName,
|
|
131
|
+
packageDir,
|
|
132
|
+
reason: "package.json is unreadable"
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
let parsedJson;
|
|
136
|
+
try {
|
|
137
|
+
parsedJson = JSON.parse(rawJson);
|
|
138
|
+
} catch {
|
|
139
|
+
return {
|
|
140
|
+
valid: false,
|
|
141
|
+
name: dirName,
|
|
142
|
+
packageDir,
|
|
143
|
+
reason: "package.json is not valid JSON"
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const pkg = PluginPackageJsonSchema.safeParse(parsedJson);
|
|
147
|
+
if (!pkg.success) {
|
|
148
|
+
const declaredName = parsedJson?.name;
|
|
149
|
+
return {
|
|
150
|
+
valid: false,
|
|
151
|
+
name: typeof declaredName === "string" && declaredName.length > 0 ? declaredName : dirName,
|
|
152
|
+
packageDir,
|
|
153
|
+
reason: formatZodIssues(pkg.error)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (!pkg.data["b4m-plugin"]) return null;
|
|
157
|
+
const manifest = pkg.data["b4m-plugin"];
|
|
158
|
+
const name = pkg.data.name;
|
|
159
|
+
const entryAbsPath = path.resolve(packageDir, manifest.entry);
|
|
160
|
+
if (!entryAbsPath.startsWith(packageDir + path.sep)) return {
|
|
161
|
+
valid: false,
|
|
162
|
+
name,
|
|
163
|
+
packageDir,
|
|
164
|
+
reason: `b4m-plugin.entry must resolve to a file inside the package: ${manifest.entry}`
|
|
165
|
+
};
|
|
166
|
+
const configKey = manifest.configKey ?? name;
|
|
167
|
+
if (RESERVED_BUILTIN_KEYS.includes(configKey)) return {
|
|
168
|
+
valid: false,
|
|
169
|
+
name,
|
|
170
|
+
packageDir,
|
|
171
|
+
reason: `configKey '${configKey}' is reserved for a built-in feature`
|
|
172
|
+
};
|
|
173
|
+
if (PROTOTYPE_POLLUTING_KEYS.has(configKey)) return {
|
|
174
|
+
valid: false,
|
|
175
|
+
name,
|
|
176
|
+
packageDir,
|
|
177
|
+
reason: `configKey '${configKey}' is not allowed`
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
valid: true,
|
|
181
|
+
name,
|
|
182
|
+
version: pkg.data.version ?? "0.0.0",
|
|
183
|
+
description: pkg.data.description ?? "",
|
|
184
|
+
packageDir,
|
|
185
|
+
entryAbsPath,
|
|
186
|
+
configKey
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
dedupeConfigKeys(descriptors) {
|
|
190
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
191
|
+
return descriptors.map((descriptor) => {
|
|
192
|
+
if (!descriptor.valid) return descriptor;
|
|
193
|
+
const owner = claimed.get(descriptor.configKey);
|
|
194
|
+
if (owner) return {
|
|
195
|
+
valid: false,
|
|
196
|
+
name: descriptor.name,
|
|
197
|
+
packageDir: descriptor.packageDir,
|
|
198
|
+
reason: `duplicate configKey '${descriptor.configKey}' already claimed by ${owner}`
|
|
199
|
+
};
|
|
200
|
+
claimed.set(descriptor.configKey, descriptor.name);
|
|
201
|
+
return descriptor;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
//#endregion
|
|
206
|
+
export { getDefaultPluginsDir as n, isFeatureEnabled as r, PluginStore as t };
|
|
@@ -50,8 +50,8 @@ function isDomainAllowed(domain, allowedDomains) {
|
|
|
50
50
|
* Intercepts outbound HTTP/HTTPS connections from sandboxed commands
|
|
51
51
|
* and filters by domain allowlist. Uses only Node.js built-ins.
|
|
52
52
|
*
|
|
53
|
-
* - CONNECT handler (HTTPS): Parse domain
|
|
54
|
-
* - HTTP handler (forward proxy): Parse host
|
|
53
|
+
* - CONNECT handler (HTTPS): Parse domain -> check allowlist -> tunnel or 403
|
|
54
|
+
* - HTTP handler (forward proxy): Parse host -> check allowlist -> forward or 403
|
|
55
55
|
*/
|
|
56
56
|
var HttpConnectProxy = class extends EventEmitter {
|
|
57
57
|
constructor(options) {
|
|
@@ -18,10 +18,10 @@ var SandboxOrchestrator = class {
|
|
|
18
18
|
* Determine whether a command should be sandboxed.
|
|
19
19
|
*
|
|
20
20
|
* Decision logic:
|
|
21
|
-
* 1. If sandbox is disabled
|
|
22
|
-
* 2. If command matches an excluded command
|
|
23
|
-
* 3. If runtime is not available
|
|
24
|
-
* 4. Otherwise
|
|
21
|
+
* 1. If sandbox is disabled -> unsandboxed (no permission change)
|
|
22
|
+
* 2. If command matches an excluded command -> unsandboxed (requires permission)
|
|
23
|
+
* 3. If runtime is not available -> unsandboxed with warning
|
|
24
|
+
* 4. Otherwise -> sandbox the command
|
|
25
25
|
*/
|
|
26
26
|
shouldSandbox(command, cwd) {
|
|
27
27
|
if (!this.config.enabled || this.config.mode === "disabled") return {
|
|
@@ -61,7 +61,7 @@ var SandboxOrchestrator = class {
|
|
|
61
61
|
getMode() {
|
|
62
62
|
return this.config.mode;
|
|
63
63
|
}
|
|
64
|
-
/** Set the sandbox mode (does not persist
|
|
64
|
+
/** Set the sandbox mode (does not persist - caller must save config) */
|
|
65
65
|
setMode(mode) {
|
|
66
66
|
this.config.mode = mode;
|
|
67
67
|
this.config.enabled = mode !== "disabled";
|
|
@@ -78,7 +78,7 @@ var SandboxOrchestrator = class {
|
|
|
78
78
|
getConfig() {
|
|
79
79
|
return this.config;
|
|
80
80
|
}
|
|
81
|
-
/** Update config (does not persist
|
|
81
|
+
/** Update config (does not persist - caller must save) */
|
|
82
82
|
updateConfig(config) {
|
|
83
83
|
this.config = config;
|
|
84
84
|
}
|
|
@@ -144,7 +144,7 @@ var SandboxOrchestrator = class {
|
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
146
|
* Extract the base command name from a full command string.
|
|
147
|
-
* e.g., "docker compose up -d"
|
|
147
|
+
* e.g., "docker compose up -d" -> "docker"
|
|
148
148
|
*/
|
|
149
149
|
getBaseCommand(command) {
|
|
150
150
|
const first = command.trim().replace(/^(\w+=\S+\s+)*/, "").split(/\s+/)[0] || "";
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import { StringDecoder } from "string_decoder";
|
|
4
|
+
//#region ../../b4m-core/services/dist/ShellSessionManager-6o8KZzl1.mjs
|
|
5
|
+
/** Thrown by spawn() when the concurrent-running-session cap is reached. */
|
|
6
|
+
var TooManyShellSessionsError = class extends Error {
|
|
7
|
+
constructor(limit) {
|
|
8
|
+
super(`Too many background shell sessions already running (limit ${limit}). Stop one with kill_background_shell before starting another.`);
|
|
9
|
+
this.limit = limit;
|
|
10
|
+
this.name = "TooManyShellSessionsError";
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 262144;
|
|
14
|
+
const DEFAULT_MAX_SESSIONS = 50;
|
|
15
|
+
const DEFAULT_MAX_RUNNING = 10;
|
|
16
|
+
const DEFAULT_RETENTION_MS = 18e5;
|
|
17
|
+
const SIGKILL_ESCALATION_MS = 5e3;
|
|
18
|
+
const INTERRUPT_CHAR = "";
|
|
19
|
+
const TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
20
|
+
"exited",
|
|
21
|
+
"killed",
|
|
22
|
+
"timed_out"
|
|
23
|
+
]);
|
|
24
|
+
/** True once a session has finished (exited, killed, or timed out) and won't change. */
|
|
25
|
+
function isTerminalShellStatus(status) {
|
|
26
|
+
return TERMINAL_STATUSES.has(status);
|
|
27
|
+
}
|
|
28
|
+
function isTerminal(status) {
|
|
29
|
+
return TERMINAL_STATUSES.has(status);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Process-global manager for backgroundable shell sessions.
|
|
33
|
+
*
|
|
34
|
+
* `bash_execute` is a CLI-only tool (see cliTools.ts), so a single in-process
|
|
35
|
+
* registry is safe - there is no Lambda cross-invocation reuse to worry about.
|
|
36
|
+
*
|
|
37
|
+
* Responsibilities:
|
|
38
|
+
* - spawn children *detached* so we can signal the whole process group and never
|
|
39
|
+
* orphan a `pnpm dev` / watcher;
|
|
40
|
+
* - retain a bounded, offset-addressable output buffer per session for polling;
|
|
41
|
+
* - expose stdin writes and interrupts to running children;
|
|
42
|
+
* - bound memory via an LRU cap + a retention window for terminal sessions;
|
|
43
|
+
* - emit lifecycle changes so the CLI (issue #310) can render live indicators.
|
|
44
|
+
*/
|
|
45
|
+
var ShellSessionManager = class {
|
|
46
|
+
constructor(options = {}) {
|
|
47
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
48
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
49
|
+
this.sequence = 0;
|
|
50
|
+
this.maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
|
51
|
+
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
52
|
+
this.maxRunning = options.maxRunning ?? DEFAULT_MAX_RUNNING;
|
|
53
|
+
this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
54
|
+
this.now = options.now ?? Date.now;
|
|
55
|
+
this.spawnFn = options.spawnFn ?? spawn;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Subscribe to lifecycle changes (create + every status transition).
|
|
59
|
+
* Returns an unsubscribe function.
|
|
60
|
+
*/
|
|
61
|
+
subscribe(listener) {
|
|
62
|
+
this.listeners.add(listener);
|
|
63
|
+
return () => {
|
|
64
|
+
this.listeners.delete(listener);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Spawn a command as a background session and return its snapshot immediately.
|
|
69
|
+
* The child is detached so its process group can be signalled as a unit.
|
|
70
|
+
*/
|
|
71
|
+
spawn(command, cwd, env) {
|
|
72
|
+
this.evictIfNeeded();
|
|
73
|
+
if (this.runningCount() >= this.maxRunning) throw new TooManyShellSessionsError(this.maxRunning);
|
|
74
|
+
const id = `sh-${(++this.sequence).toString(36)}-${this.now().toString(36)}`;
|
|
75
|
+
const child = this.spawnFn("bash", ["-c", command], {
|
|
76
|
+
cwd,
|
|
77
|
+
env,
|
|
78
|
+
detached: true,
|
|
79
|
+
stdio: [
|
|
80
|
+
"pipe",
|
|
81
|
+
"pipe",
|
|
82
|
+
"pipe"
|
|
83
|
+
]
|
|
84
|
+
});
|
|
85
|
+
const snapshot = {
|
|
86
|
+
id,
|
|
87
|
+
command,
|
|
88
|
+
cwd,
|
|
89
|
+
status: "running",
|
|
90
|
+
exitCode: null,
|
|
91
|
+
startTime: this.now(),
|
|
92
|
+
pid: child.pid,
|
|
93
|
+
totalOutputChars: 0
|
|
94
|
+
};
|
|
95
|
+
const session = {
|
|
96
|
+
snapshot,
|
|
97
|
+
child,
|
|
98
|
+
buffer: "",
|
|
99
|
+
stdoutDecoder: new StringDecoder("utf8"),
|
|
100
|
+
stderrDecoder: new StringDecoder("utf8")
|
|
101
|
+
};
|
|
102
|
+
this.sessions.set(id, session);
|
|
103
|
+
child.stdout.on("data", (data) => this.append(session, session.stdoutDecoder.write(data)));
|
|
104
|
+
child.stderr.on("data", (data) => this.append(session, session.stderrDecoder.write(data)));
|
|
105
|
+
child.on("close", (exitCode) => {
|
|
106
|
+
const tail = session.stdoutDecoder.end() + session.stderrDecoder.end();
|
|
107
|
+
if (tail) this.append(session, tail);
|
|
108
|
+
if (isTerminal(session.snapshot.status)) return;
|
|
109
|
+
this.transition(session, "exited", exitCode);
|
|
110
|
+
});
|
|
111
|
+
child.on("error", (error) => {
|
|
112
|
+
this.append(session, `\n[shell error] ${error.message}\n`);
|
|
113
|
+
if (!isTerminal(session.snapshot.status)) this.transition(session, "killed", null);
|
|
114
|
+
});
|
|
115
|
+
child.stdin.on("error", (error) => {
|
|
116
|
+
if (error.code !== "EPIPE") this.append(session, `\n[stdin error] ${error.message}\n`);
|
|
117
|
+
});
|
|
118
|
+
this.emit(session);
|
|
119
|
+
return { ...snapshot };
|
|
120
|
+
}
|
|
121
|
+
/** Public snapshot for a session, or undefined if unknown. */
|
|
122
|
+
get(id) {
|
|
123
|
+
const session = this.sessions.get(id);
|
|
124
|
+
return session ? { ...session.snapshot } : void 0;
|
|
125
|
+
}
|
|
126
|
+
/** Snapshots of all retained sessions, oldest first. */
|
|
127
|
+
list() {
|
|
128
|
+
return Array.from(this.sessions.values()).map((s) => ({ ...s.snapshot }));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read output produced since `sinceOffset` (absolute char cursor).
|
|
132
|
+
* Omit `sinceOffset` to read the entire retained buffer.
|
|
133
|
+
*/
|
|
134
|
+
getOutput(id, sinceOffset) {
|
|
135
|
+
const session = this.sessions.get(id);
|
|
136
|
+
if (!session) return void 0;
|
|
137
|
+
const { snapshot, buffer } = session;
|
|
138
|
+
const bufferStart = snapshot.totalOutputChars - buffer.length;
|
|
139
|
+
const requested = sinceOffset ?? bufferStart;
|
|
140
|
+
const clamped = Math.max(requested, bufferStart);
|
|
141
|
+
return {
|
|
142
|
+
output: buffer.slice(clamped - bufferStart),
|
|
143
|
+
offset: snapshot.totalOutputChars,
|
|
144
|
+
truncated: requested < bufferStart,
|
|
145
|
+
status: snapshot.status,
|
|
146
|
+
exitCode: snapshot.exitCode
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Write to a running child's stdin. A lone INTERRUPT_CHAR (Ctrl-C) is treated
|
|
151
|
+
* as an interrupt (SIGINT) rather than literal input. Returns false if the
|
|
152
|
+
* session is unknown or already terminal.
|
|
153
|
+
*/
|
|
154
|
+
writeStdin(id, chars) {
|
|
155
|
+
const session = this.sessions.get(id);
|
|
156
|
+
if (!session || isTerminal(session.snapshot.status)) return false;
|
|
157
|
+
if (chars.includes(INTERRUPT_CHAR)) {
|
|
158
|
+
this.signalGroup(session, "SIGINT");
|
|
159
|
+
const rest = chars.split(INTERRUPT_CHAR).join("");
|
|
160
|
+
if (rest.length > 0) session.child.stdin.write(rest);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
session.child.stdin.write(chars);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Terminate a running session's process group (SIGTERM, escalating to SIGKILL).
|
|
168
|
+
* Returns false if the session is unknown or already terminal.
|
|
169
|
+
*/
|
|
170
|
+
kill(id) {
|
|
171
|
+
const session = this.sessions.get(id);
|
|
172
|
+
if (!session || isTerminal(session.snapshot.status)) return false;
|
|
173
|
+
this.terminate(session, "killed");
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
/** Mark a session as timed out and terminate its group. */
|
|
177
|
+
markTimedOut(id) {
|
|
178
|
+
const session = this.sessions.get(id);
|
|
179
|
+
if (!session || isTerminal(session.snapshot.status)) return false;
|
|
180
|
+
this.terminate(session, "timed_out");
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
/** Terminate every running session. Call on CLI shutdown to avoid orphans. */
|
|
184
|
+
killAll() {
|
|
185
|
+
for (const session of this.sessions.values()) if (!isTerminal(session.snapshot.status)) this.terminate(session, "killed");
|
|
186
|
+
}
|
|
187
|
+
/** Number of retained sessions (for tests/monitoring). */
|
|
188
|
+
get size() {
|
|
189
|
+
return this.sessions.size;
|
|
190
|
+
}
|
|
191
|
+
/** Number of sessions currently running (non-terminal). */
|
|
192
|
+
runningCount() {
|
|
193
|
+
let count = 0;
|
|
194
|
+
for (const session of this.sessions.values()) if (!isTerminal(session.snapshot.status)) count++;
|
|
195
|
+
return count;
|
|
196
|
+
}
|
|
197
|
+
terminate(session, status) {
|
|
198
|
+
this.transition(session, status, null);
|
|
199
|
+
this.signalGroup(session, "SIGTERM");
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
if (session.child.exitCode === null && session.child.signalCode === null) this.signalGroup(session, "SIGKILL");
|
|
202
|
+
}, SIGKILL_ESCALATION_MS).unref?.();
|
|
203
|
+
}
|
|
204
|
+
/** Send a signal to the child's whole process group; ignore already-dead. */
|
|
205
|
+
signalGroup(session, signal) {
|
|
206
|
+
const { pid } = session.child;
|
|
207
|
+
if (pid === void 0) return;
|
|
208
|
+
try {
|
|
209
|
+
process.kill(-pid, signal);
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
append(session, chunk) {
|
|
213
|
+
session.snapshot.totalOutputChars += chunk.length;
|
|
214
|
+
const combined = session.buffer + chunk;
|
|
215
|
+
session.buffer = combined.length > this.maxOutputChars ? combined.slice(combined.length - this.maxOutputChars) : combined;
|
|
216
|
+
}
|
|
217
|
+
transition(session, status, exitCode) {
|
|
218
|
+
session.snapshot.status = status;
|
|
219
|
+
session.snapshot.exitCode = exitCode;
|
|
220
|
+
if (isTerminal(status)) session.snapshot.endTime = this.now();
|
|
221
|
+
this.emit(session);
|
|
222
|
+
}
|
|
223
|
+
emit(session) {
|
|
224
|
+
const snapshot = { ...session.snapshot };
|
|
225
|
+
for (const listener of this.listeners) try {
|
|
226
|
+
listener(snapshot);
|
|
227
|
+
} catch {}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Evict terminal sessions to keep memory bounded: first drop any past the
|
|
231
|
+
* retention window, then, if still at the cap, drop the oldest terminal ones.
|
|
232
|
+
*/
|
|
233
|
+
evictIfNeeded() {
|
|
234
|
+
const cutoff = this.now() - this.retentionMs;
|
|
235
|
+
for (const [id, session] of this.sessions) if (isTerminal(session.snapshot.status) && (session.snapshot.endTime ?? 0) < cutoff) this.sessions.delete(id);
|
|
236
|
+
if (this.sessions.size < this.maxSessions) return;
|
|
237
|
+
const terminalOldestFirst = Array.from(this.sessions.entries()).filter(([, s]) => isTerminal(s.snapshot.status)).sort((a, b) => (a[1].snapshot.endTime ?? 0) - (b[1].snapshot.endTime ?? 0));
|
|
238
|
+
for (const [id] of terminalOldestFirst) {
|
|
239
|
+
if (this.sessions.size < this.maxSessions) break;
|
|
240
|
+
this.sessions.delete(id);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const SINGLETON_KEY = Symbol.for("@bike4mind/services:ShellSessionManager");
|
|
245
|
+
/** Lazily-created process-global manager used by the bash_execute tool family. */
|
|
246
|
+
function getShellSessionManager() {
|
|
247
|
+
const host = globalThis;
|
|
248
|
+
if (!host[SINGLETON_KEY]) host[SINGLETON_KEY] = new ShellSessionManager();
|
|
249
|
+
return host[SINGLETON_KEY];
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
export { isTerminalShellStatus as n, getShellSessionManager as t };
|
|
@@ -41,7 +41,7 @@ var ViolationLogStore = class {
|
|
|
41
41
|
throw error;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
/** Record a new violation (converts Date
|
|
44
|
+
/** Record a new violation (converts Date -> epoch ms, appends JSONL) */
|
|
45
45
|
async record(violation) {
|
|
46
46
|
const entry = {
|
|
47
47
|
type: violation.type,
|