feat: notify on exec exit

This commit is contained in:
Peter Steinberger
2026-01-17 05:43:27 +00:00
parent 68d35be383
commit 07a3db153d
18 changed files with 130 additions and 32 deletions

View File

@@ -18,6 +18,7 @@
- Tools: add Firecrawl fallback for `web_fetch` when configured. - Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites. - Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails. - Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block. - Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels. - Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI. - UI: allow deleting sessions from the Control UI.

View File

@@ -39,6 +39,7 @@ Config (preferred):
- `tools.exec.backgroundMs` (default 10000) - `tools.exec.backgroundMs` (default 10000)
- `tools.exec.timeoutSec` (default 1800) - `tools.exec.timeoutSec` (default 1800)
- `tools.exec.cleanupMs` (default 1800000) - `tools.exec.cleanupMs` (default 1800000)
- `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits.
## process tool ## process tool

View File

@@ -1745,10 +1745,10 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
- `backgroundMs`: time before auto-background (ms, default 10000) - `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
- `notifyOnExit`: enqueue a system event + request heartbeat when backgrounded exec exits (default true)
- `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false) - `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false)
- `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`) - `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`)
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias). Note: `applyPatch` is only under `tools.exec`.
Legacy: `tools.bash` is still accepted as an alias.
`tools.web` configures web search + fetch tools: `tools.web` configures web search + fetch tools:
- `tools.web.search.enabled` (default: true when key is present) - `tools.web.search.enabled` (default: true when key is present)

View File

@@ -37,7 +37,7 @@ The tool accepts a single `input` string that wraps one or more file operations:
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`. - Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
- OpenAI-only (including OpenAI Codex). Optionally gate by model via - OpenAI-only (including OpenAI Codex). Optionally gate by model via
`tools.exec.applyPatch.allowModels`. `tools.exec.applyPatch.allowModels`.
- Config is only under `tools.exec` (no `tools.bash` alias). - Config is only under `tools.exec`.
## Example ## Example

View File

@@ -22,6 +22,10 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output. - Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host). Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
## Config
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
## Examples ## Examples
Foreground: Foreground:
@@ -53,4 +57,4 @@ Enable it explicitly:
Notes: Notes:
- Only available for OpenAI/OpenAI Codex models. - Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`. - Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch` (no `tools.bash` alias). - Config lives under `tools.exec.applyPatch`.

View File

@@ -23,6 +23,9 @@ export interface ProcessSession {
id: string; id: string;
command: string; command: string;
scopeKey?: string; scopeKey?: string;
sessionKey?: string;
notifyOnExit?: boolean;
exitNotified?: boolean;
child?: ChildProcessWithoutNullStreams; child?: ChildProcessWithoutNullStreams;
stdin?: SessionStdin; stdin?: SessionStdin;
pid?: number; pid?: number;

View File

@@ -3,13 +3,17 @@ import { randomUUID } from "node:crypto";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo } from "../logger.js"; import { logInfo } from "../logger.js";
import { import {
type ProcessSession,
type SessionStdin, type SessionStdin,
addSession, addSession,
appendOutput, appendOutput,
markBackgrounded, markBackgrounded,
markExited, markExited,
tail,
} from "./bash-process-registry.js"; } from "./bash-process-registry.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js"; import type { BashSandboxConfig } from "./bash-tools.shared.js";
import { import {
@@ -34,6 +38,7 @@ const DEFAULT_MAX_OUTPUT = clampNumber(
); );
const DEFAULT_PATH = const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
const DEFAULT_NOTIFY_TAIL_CHARS = 400;
type PtyExitEvent = { exitCode: number; signal?: number }; type PtyExitEvent = { exitCode: number; signal?: number };
type PtyListener<T> = (event: T) => void; type PtyListener<T> = (event: T) => void;
@@ -62,6 +67,8 @@ export type ExecToolDefaults = {
elevated?: ExecElevatedDefaults; elevated?: ExecElevatedDefaults;
allowBackground?: boolean; allowBackground?: boolean;
scopeKey?: string; scopeKey?: string;
sessionKey?: string;
notifyOnExit?: boolean;
cwd?: string; cwd?: string;
}; };
@@ -117,6 +124,28 @@ export type ExecToolDetails =
cwd?: string; cwd?: string;
}; };
function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "failed") {
if (!session.backgrounded || !session.notifyOnExit || session.exitNotified) return;
const sessionKey = session.sessionKey?.trim();
if (!sessionKey) return;
session.exitNotified = true;
const exitLabel = session.exitSignal
? `signal ${session.exitSignal}`
: `code ${session.exitCode ?? 0}`;
const output = normalizeNotifyOutput(
tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
const summary = output
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
enqueueSystemEvent(summary, { sessionKey });
requestHeartbeatNow({ reason: `exec:${session.id}:exit` });
}
export function createExecTool( export function createExecTool(
defaults?: ExecToolDefaults, defaults?: ExecToolDefaults,
// biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance. // biome-ignore lint/suspicious/noExplicitAny: TypeBox schema type from pi-agent-core uses a different module instance.
@@ -132,6 +161,8 @@ export function createExecTool(
typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0 typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
? defaults.timeoutSec ? defaults.timeoutSec
: 1800; : 1800;
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
return { return {
name: "exec", name: "exec",
@@ -308,6 +339,9 @@ export function createExecTool(
id: sessionId, id: sessionId,
command: params.command, command: params.command,
scopeKey: defaults?.scopeKey, scopeKey: defaults?.scopeKey,
sessionKey: notifySessionKey,
notifyOnExit,
exitNotified: false,
child: child ?? undefined, child: child ?? undefined,
stdin, stdin,
pid: child?.pid ?? pty?.pid, pid: child?.pid ?? pty?.pid,
@@ -347,6 +381,7 @@ export function createExecTool(
const finalizeTimeout = () => { const finalizeTimeout = () => {
if (session.exited) return; if (session.exited) return;
markExited(session, null, "SIGKILL", "failed"); markExited(session, null, "SIGKILL", "failed");
maybeNotifyOnExit(session, "failed");
if (settled || !rejectFn) return; if (settled || !rejectFn) return;
const aggregated = session.aggregated.trim(); const aggregated = session.aggregated.trim();
const reason = `Command timed out after ${effectiveTimeout} seconds`; const reason = `Command timed out after ${effectiveTimeout} seconds`;
@@ -477,6 +512,7 @@ export function createExecTool(
const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut; const isSuccess = code === 0 && !wasSignal && !signal?.aborted && !timedOut;
const status: "completed" | "failed" = isSuccess ? "completed" : "failed"; const status: "completed" | "failed" = isSuccess ? "completed" : "failed";
markExited(session, code, exitSignal, status); markExited(session, code, exitSignal, status);
maybeNotifyOnExit(session, status);
if (!session.child && session.stdin) { if (!session.child && session.stdin) {
session.stdin.destroyed = true; session.stdin.destroyed = true;
} }
@@ -536,6 +572,7 @@ export function createExecTool(
if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutTimer) clearTimeout(timeoutTimer);
if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer); if (timeoutFinalizeTimer) clearTimeout(timeoutFinalizeTimer);
markExited(session, null, null, "failed"); markExited(session, null, null, "failed");
maybeNotifyOnExit(session, "failed");
settle(() => reject(err)); settle(() => reject(err));
}); });
} }

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry.js"; import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js"; import { createExecTool, createProcessTool, execTool, processTool } from "./bash-tools.js";
import { buildDockerExecArgs } from "./bash-tools.shared.js"; import { buildDockerExecArgs } from "./bash-tools.shared.js";
import { sanitizeBinaryOutput } from "./shell-utils.js"; import { sanitizeBinaryOutput } from "./shell-utils.js";
@@ -42,6 +43,7 @@ async function waitForCompletion(sessionId: string) {
beforeEach(() => { beforeEach(() => {
resetProcessRegistryForTests(); resetProcessRegistryForTests();
resetSystemEventsForTest();
}); });
describe("exec tool backgrounding", () => { describe("exec tool backgrounding", () => {
@@ -241,6 +243,36 @@ describe("exec tool backgrounding", () => {
}); });
}); });
describe("exec notifyOnExit", () => {
it("enqueues a system event when a backgrounded exec exits", async () => {
const tool = createExecTool({
allowBackground: true,
backgroundMs: 0,
notifyOnExit: true,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call1", {
command: echoAfterDelay("notify"),
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + (isWin ? 8000 : 2000);
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
expect(finished).toBeTruthy();
const events = peekSystemEvents("agent:main:main");
expect(events.some((event) => event.includes(sessionId.slice(0, 8)))).toBe(true);
});
});
describe("buildDockerExecArgs", () => { describe("buildDockerExecArgs", () => {
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => { it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
const args = buildDockerExecArgs({ const args = buildDockerExecArgs({

View File

@@ -11,10 +11,8 @@ export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaults | undefined { export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaults | undefined {
const tools = config?.tools; const tools = config?.tools;
if (!tools) return undefined; if (!tools?.exec) return undefined;
if (!tools.exec) return tools.bash; return tools.exec;
if (!tools.bash) return tools.exec;
return { ...tools.bash, ...tools.exec };
} }
export function describeUnknownError(error: unknown): string { export function describeUnknownError(error: unknown): string {

View File

@@ -181,6 +181,7 @@ export function createClawdbotCodingTools(options?: {
cwd: options?.workspaceDir, cwd: options?.workspaceDir,
allowBackground, allowBackground,
scopeKey, scopeKey,
sessionKey: options?.sessionKey,
sandbox: sandbox sandbox: sandbox
? { ? {
containerName: sandbox.containerName, containerName: sandbox.containerName,

View File

@@ -328,11 +328,14 @@ export async function handleBashChatCommand(params: {
try { try {
const foregroundMs = resolveForegroundMs(params.cfg); const foregroundMs = resolveForegroundMs(params.cfg);
const shouldBackgroundImmediately = foregroundMs <= 0; const shouldBackgroundImmediately = foregroundMs <= 0;
const timeoutSec = params.cfg.tools?.exec?.timeoutSec ?? params.cfg.tools?.bash?.timeoutSec; const timeoutSec = params.cfg.tools?.exec?.timeoutSec;
const notifyOnExit = params.cfg.tools?.exec?.notifyOnExit;
const execTool = createExecTool({ const execTool = createExecTool({
scopeKey: CHAT_BASH_SCOPE_KEY, scopeKey: CHAT_BASH_SCOPE_KEY,
allowBackground: true, allowBackground: true,
timeoutSec, timeoutSec,
sessionKey: params.sessionKey,
notifyOnExit,
elevated: { elevated: {
enabled: params.elevated.enabled, enabled: params.elevated.enabled,
allowed: params.elevated.allowed, allowed: params.elevated.allowed,

View File

@@ -141,6 +141,18 @@ describe("legacy config detection", () => {
}); });
expect((res.config as { agent?: unknown }).agent).toBeUndefined(); expect((res.config as { agent?: unknown }).agent).toBeUndefined();
}); });
it("migrates tools.bash to tools.exec", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
tools: {
bash: { timeoutSec: 12 },
},
});
expect(res.changes).toContain("Moved tools.bash → tools.exec.");
expect(res.config?.tools?.exec).toEqual({ timeoutSec: 12 });
expect((res.config?.tools as { bash?: unknown } | undefined)?.bash).toBeUndefined();
});
it("accepts per-agent tools.elevated overrides", async () => { it("accepts per-agent tools.elevated overrides", async () => {
vi.resetModules(); vi.resetModules();
const { validateConfigObject } = await import("./config.js"); const { validateConfigObject } = await import("./config.js");

View File

@@ -24,6 +24,22 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".'); changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
}, },
}, },
{
id: "tools.bash->tools.exec",
describe: "Move tools.bash to tools.exec",
apply: (raw, changes) => {
const tools = ensureRecord(raw, "tools");
const bash = getRecord(tools.bash);
if (!bash) return;
if (tools.exec === undefined) {
tools.exec = bash;
changes.push("Moved tools.bash → tools.exec.");
} else {
changes.push("Removed tools.bash (tools.exec already set).");
}
delete tools.bash;
},
},
{ {
id: "agent.defaults-v2", id: "agent.defaults-v2",
describe: "Move agent config to agents.defaults and tools", describe: "Move agent config to agents.defaults and tools",
@@ -59,13 +75,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
const bash = getRecord(agent.bash); const bash = getRecord(agent.bash);
if (bash) { if (bash) {
if (tools.exec === undefined && tools.bash === undefined) { if (tools.exec === undefined) {
tools.exec = bash; tools.exec = bash;
changes.push("Moved agent.bash → tools.exec."); changes.push("Moved agent.bash → tools.exec.");
} else if (tools.exec !== undefined) {
changes.push("Removed agent.bash (tools.exec already set).");
} else { } else {
changes.push("Removed agent.bash (tools.bash already set)."); changes.push("Removed agent.bash (tools.exec already set).");
} }
} }

View File

@@ -85,6 +85,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
message: message:
"agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (auto-migrated on load).", "agent.* was moved; use agents.defaults (and tools.* for tool/elevated/exec settings) instead (auto-migrated on load).",
}, },
{
path: ["tools", "bash"],
message: "tools.bash was removed; use tools.exec instead (auto-migrated on load).",
},
{ {
path: ["agent", "model"], path: ["agent", "model"],
message: message:

View File

@@ -135,6 +135,7 @@ const FIELD_LABELS: Record<string, string> = {
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider", "agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"tools.exec.applyPatch.enabled": "Enable apply_patch", "tools.exec.applyPatch.enabled": "Enable apply_patch",
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
"tools.exec.notifyOnExit": "Exec Notify On Exit",
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
@@ -288,6 +289,8 @@ const FIELD_HELP: Record<string, string> = {
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels": "tools.exec.applyPatch.allowModels":
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
"tools.exec.notifyOnExit":
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
"tools.message.allowCrossContextSend": "tools.message.allowCrossContextSend":
"Legacy override: allow cross-context sends across all providers.", "Legacy override: allow cross-context sends across all providers.",
"tools.message.crossContext.allowWithinProvider": "tools.message.crossContext.allowWithinProvider":

View File

@@ -263,6 +263,8 @@ export type ToolsConfig = {
timeoutSec?: number; timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */ /** How long to keep finished sessions in memory (ms). */
cleanupMs?: number; cleanupMs?: number;
/** Emit a system event and heartbeat when a backgrounded exec exits. */
notifyOnExit?: boolean;
/** apply_patch subtool configuration (experimental). */ /** apply_patch subtool configuration (experimental). */
applyPatch?: { applyPatch?: {
/** Enable apply_patch for OpenAI models (default: false). */ /** Enable apply_patch for OpenAI models (default: false). */
@@ -274,15 +276,6 @@ export type ToolsConfig = {
allowModels?: string[]; allowModels?: string[];
}; };
}; };
/** @deprecated Use tools.exec. */
bash?: {
/** Default time (ms) before a bash command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing bash commands. */
timeoutSec?: number;
/** How long to keep finished sessions in memory (ms). */
cleanupMs?: number;
};
/** Sub-agent tool policy defaults (deny wins). */ /** Sub-agent tool policy defaults (deny wins). */
subagents?: { subagents?: {
/** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */ /** Default model selection for spawned sub-agents (string or {primary,fallbacks}). */

View File

@@ -324,6 +324,7 @@ export const ToolsSchema = z
backgroundMs: z.number().int().positive().optional(), backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(), timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(), cleanupMs: z.number().int().positive().optional(),
notifyOnExit: z.boolean().optional(),
applyPatch: z applyPatch: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
@@ -332,13 +333,6 @@ export const ToolsSchema = z
.optional(), .optional(),
}) })
.optional(), .optional(),
bash: z
.object({
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
})
.optional(),
subagents: z subagents: z
.object({ .object({
tools: ToolPolicySchema, tools: ToolPolicySchema,

View File

@@ -3,8 +3,6 @@ import type { MsgContext } from "../auto-reply/templating.js";
import { applyTemplate } from "../auto-reply/templating.js"; import { applyTemplate } from "../auto-reply/templating.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { resolveApiKeyForProvider } from "../agents/model-auth.js"; import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { ensureClawdbotModelsJson } from "../agents/models-config.js";
import { minimaxUnderstandImage } from "../agents/minimax-vlm.js";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { runExec } from "../process/exec.js"; import { runExec } from "../process/exec.js";
import type { import type {