@bike4mind/cli 0.18.5 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +204 -35
  3. package/bin/bike4mind-cli.mjs +137 -24
  4. package/bin/hearth-hook.mjs +292 -0
  5. package/dist/AgentHistoryStore-BQiATPsQ.mjs +35755 -0
  6. package/dist/ApiClient-BPmlalut.mjs +277 -0
  7. package/dist/{ConfigStore-D39UqFnY.mjs → ConfigStore-CNfbeaJf.mjs} +6702 -4122
  8. package/dist/{ImageStore-BVmEG1xc.mjs → ImageStore-kVo-oHoS.mjs} +2 -2
  9. package/dist/PluginStore-DwvOJ-G3.mjs +206 -0
  10. package/dist/ProxyManager-Bqr7Lmsd.mjs +3 -0
  11. package/dist/{ProxyManager-CV94yZUW.mjs → ProxyManager-C5H0pUyK.mjs} +2 -2
  12. package/dist/{SandboxOrchestrator-BS6gALNq.mjs → SandboxOrchestrator-BFPVpmB5.mjs} +1 -1
  13. package/dist/{SandboxOrchestrator-BoINxbX4.mjs → SandboxOrchestrator-C8uleDn2.mjs} +7 -7
  14. package/dist/ShellSessionManager-6o8KZzl1-vrbPAUTq.mjs +252 -0
  15. package/dist/{ViolationLogStore-B-plqJfn.mjs → ViolationLogStore-byEhxa2A.mjs} +1 -1
  16. package/dist/WorkItemsClient-Cow6nXx7.mjs +382 -0
  17. package/dist/{bashExecute-B1N1lMOS-TZVDbcQ4.mjs → bashExecute-CrdPpBqk-DCATrE-D.mjs} +116 -16
  18. package/dist/buildAgent-DwPvcTpz.mjs +824 -0
  19. package/dist/commands/acpCommand.mjs +798 -0
  20. package/dist/commands/apiCommand.mjs +14 -16
  21. package/dist/commands/doctorCommand.mjs +5 -5
  22. package/dist/commands/envCommand.mjs +1 -1
  23. package/dist/commands/headlessCommand.mjs +272 -76
  24. package/dist/commands/mcpCommand.mjs +14 -1
  25. package/dist/commands/pluginCommand.mjs +232 -0
  26. package/dist/commands/updateCommand.mjs +10 -9
  27. package/dist/{grepSearch-DJs-cubo-Bm0Y8oS3.mjs → grepSearch-BaYUfIYs-C-fxWc9G.mjs} +3 -3
  28. package/dist/index.mjs +3284 -2307
  29. package/dist/{package-I_v_WFUn.mjs → package-CxHSRXdp.mjs} +1 -1
  30. package/dist/serve-Du3HiqAH.mjs +772 -0
  31. package/dist/store-BG3e54c8.mjs +3 -0
  32. package/dist/{store-DV5s-qni.mjs → store-CvjTpQPs.mjs} +70 -3
  33. package/dist/{terminalSetup-BbJt04ZG.mjs → terminalSetup-DjXAwpDy.mjs} +2 -3
  34. package/dist/{treeSitterEngine-BRbQ9b7I.mjs → treeSitterEngine-QBE3YkmG.mjs} +51 -1
  35. package/dist/{updateChecker-C8xsNY2L.mjs → updateChecker-CQW8bxo6.mjs} +10 -10
  36. package/package.json +48 -43
  37. package/dist/BackgroundAgentManager-D-xsWd3C.mjs +0 -27303
  38. package/dist/ProxyManager-ByuAHFMq.mjs +0 -3
  39. package/dist/store-DgzCTRkN.mjs +0 -3
  40. package/dist/utils-Cdktpk_k.mjs +0 -158
  41. 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 = 750 * 1024;
27
+ this.MAX_RAW_SIZE = 768e3;
28
28
  }
29
29
  static {
30
- this.MAX_PRECOMPRESS_SIZE = 50 * 1024 * 1024;
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 };
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { t as ProxyManager } from "./ProxyManager-C5H0pUyK.mjs";
3
+ export { ProxyManager };
@@ -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 check allowlist tunnel or 403
54
- * - HTTP handler (forward proxy): Parse host check allowlist forward or 403
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) {
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import { t as SandboxOrchestrator } from "./SandboxOrchestrator-BoINxbX4.mjs";
2
+ import { t as SandboxOrchestrator } from "./SandboxOrchestrator-C8uleDn2.mjs";
3
3
  export { SandboxOrchestrator };
@@ -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 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
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 caller must save config) */
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 caller must save) */
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" "docker"
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 epoch ms, appends JSONL) */
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,