Forked from khtsly/computer
src / toolsProvider.ts
/**
* @file toolsProvider.ts
* Registers all computer tools with LM Studio.
*
* Tools:
* Execute — run any shell command (persistent session)
* WriteFile — create or overwrite a file
* AppendFile — append content to an existing file
* ReadFile — read file contents (with line range support)
* StrReplace — surgically edit files (supports multiple replacements per call)
* InsertLines — insert lines at a specific position
* ListDirectory — list directory contents
* MoveFile — move or rename a file/directory
* CopyFile — copy a file or directory
* SearchInFiles — grep across files (like IDE "find in project")
* SetEnvVar — set a persistent environment variable
* UploadFile — transfer a file from the host into the container
* DownloadFile — pull a file from the container to the host
* ExecuteBackground — run a long command without blocking
* ReadProcessLogs — read output from a background process
* KillBackground — stop a background process by handle ID
* KillProcess — kill any process by PID
* ComputerStatus — environment info and resource usage
* RestartComputer — restart the container (keeps files)
* RebuildComputer — destroy and recreate the container
* ResetShell — reset just the shell session
*/
import { tool } from "@lmstudio/sdk";
import { homedir } from "os";
import { join as pathJoin } from "path";
import { z } from "zod";
import { configSchematics } from "./config";
import * as engine from "./container/engine";
import { checkCommand } from "./safety/guard";
import {
CONTAINER_WORKDIR,
MAX_FILE_READ_BYTES,
MAX_FILE_WRITE_BYTES,
MAX_TIMEOUT_SECONDS,
} from "./constants";
import type { PluginController } from "./pluginTypes";
import type { ComputerPluginConfig, TurnBudget } from "./types";
import type { NetworkMode, ContainerImage } from "./constants";
function readConfig(ctl: PluginController): ComputerPluginConfig {
const c = ctl.getPluginConfig(configSchematics);
return {
internetAccess: c.get("internetAccess") === "on",
persistenceMode: c.get("persistenceMode") || "persistent",
baseImage: (c.get("baseImage") || "ubuntu:24.04") as ContainerImage,
cpuLimit: c.get("cpuLimit") ?? 2,
memoryLimitMB: c.get("memoryLimitMB") ?? 1024,
diskLimitMB: c.get("diskLimitMB") ?? 4096,
commandTimeout: c.get("commandTimeout") ?? 30,
maxOutputSize: (c.get("maxOutputSize") ?? 32) * 1024,
maxToolCallsPerTurn: c.get("maxToolCallsPerTurn") ?? 10,
autoInstallPreset: c.get("autoInstallPreset") || "minimal",
portForwards: c.get("portForwards") || "",
hostMountPath: c.get("hostMountPath") || "",
wslDistroName: c.get("wslDistroName") || "",
strictSafety: c.get("strictSafety") === "on",
autoInjectContext: c.get("autoInjectContext") === "on",
};
}
const turnBudget: TurnBudget = { turnId: 0, callsUsed: 0, maxCalls: 10 };
export function advanceTurn(maxCalls: number): void {
turnBudget.turnId++;
turnBudget.callsUsed = 0;
turnBudget.maxCalls = maxCalls;
}
function consumeBudget(): string | null {
turnBudget.callsUsed++;
if (turnBudget.callsUsed > turnBudget.maxCalls) {
return (
`Tool call budget exhausted (${turnBudget.maxCalls} calls per turn). ` +
`Stop using tools and summarise what you have done so far. ` +
`The budget resets when the user sends the next message.`
);
}
return null;
}
/** Budget placed FIRST in every tool response so the model sees it immediately. */
function budgetStatus() {
return {
callsUsed: turnBudget.callsUsed,
callsRemaining: Math.max(0, turnBudget.maxCalls - turnBudget.callsUsed),
maxPerTurn: turnBudget.maxCalls,
};
}
function classifyError(
raw: string,
context?: { filePath?: string; command?: string; isNetwork?: boolean },
): { error: string; hint: string } {
const m = raw.toLowerCase();
const fp = context?.filePath ?? "";
if (
m.includes("no container runtime found") ||
m.includes("please install docker") ||
m.includes("dockerdesktoplinuxengine") ||
(m.includes("docker") && m.includes("daemon is not running")) ||
(m.includes("cannot connect") && m.includes("docker")) ||
(m.includes("open //./pipe/") && m.includes("system cannot find"))
) {
return {
error: "No container runtime found. Docker Desktop (or Podman) is not installed or not running on this machine.",
hint:
"ACTION REQUIRED — tell the user: Docker Desktop must be installed and running to use this plugin. " +
"Install from https://docs.docker.com/desktop/ (Windows/Mac) or https://podman.io/ (Linux). " +
"On Windows, also ensure Docker Desktop is set to Linux containers mode (right-click the tray icon).",
};
}
if (m.includes("no such file") || (m.includes("not found") && fp)) {
const dir = fp.includes("/") ? fp.slice(0, fp.lastIndexOf("/")) || "/" : CONTAINER_WORKDIR;
return {
error: `File not found: ${fp}`,
hint: `Use ListDirectory on "${dir}" to check what exists there.`,
};
}
if (m.includes("permission denied") || m.includes("eacces")) {
return {
error: `Permission denied: ${fp || raw.slice(0, 80)}`,
hint: `Try running with sudo, or fix permissions: chmod +rw '${fp || "<path>"}'.`,
};
}
if (m.includes("is a directory")) {
return {
error: `Path is a directory, not a file: ${fp}`,
hint: `Use ListDirectory to browse its contents, or specify a file path.`,
};
}
if (m.includes("no space left") || m.includes("disk quota")) {
return {
error: "Disk full or quota exceeded.",
hint: `Run: df -h && du -sh ${CONTAINER_WORKDIR}/* to find what's using space.`,
};
}
if (m.includes("cannot allocate memory") || m.includes("out of memory") || m.includes("oom")) {
return {
error: "Out of memory.",
hint: `Use ComputerStatus to check memory usage. Consider increasing Memory Limit in plugin settings.`,
};
}
if (
m.includes("command not found") ||
m.includes("executable file not found") ||
m.includes("not found in $path")
) {
const cmd = context?.command?.split(" ")[0] ?? "the command";
return {
error: `Command not found: ${cmd}`,
hint: `Install it first — e.g. apt-get install ${cmd} (Ubuntu) or apk add ${cmd} (Alpine). Make sure Internet Access is enabled in settings.`,
};
}
if (
m.includes("temporary failure resolving") ||
m.includes("could not resolve") ||
m.includes("network unreachable") ||
(m.includes("connection refused") && context?.isNetwork)
) {
return {
error: "Network/DNS failure inside container.",
hint: `Internet Access may be disabled. Enable it in plugin settings and call RebuildComputer.`,
};
}
if (m.includes("timed out") || m.includes("timeout")) {
return {
error: "Command timed out.",
hint: `For long-running tasks use ExecuteBackground instead, or increase Command Timeout in plugin settings.`,
};
}
if (
m.includes("container") &&
(m.includes("not running") || m.includes("not found") || m.includes("no such container"))
) {
return {
error: "Container is not running.",
hint: `Call ComputerStatus to wake it up, or call RebuildComputer if it keeps failing.`,
};
}
if (m.includes("string not found")) {
return {
error: raw.slice(0, 200),
hint: `Use ReadFile to view the current file contents before retrying StrReplace.`,
};
}
if (m.includes("appears") && m.includes("times")) {
return {
error: raw.slice(0, 200),
hint: `Include more surrounding lines in oldStr to make the match unique.`,
};
}
return {
error: raw.length > 300 ? raw.slice(0, 300) + "…" : raw,
hint: `If this persists, try ResetShell or RestartComputer.`,
};
}
async function ensureContainer(
cfg: ComputerPluginConfig,
status: (msg: string) => void,
): Promise<void> {
await engine.verifyHealth();
if (engine.isReady()) return;
status("Starting computer… (first use may take a moment to pull the image)");
await engine.ensureReady({
image: cfg.baseImage as ContainerImage,
network: (cfg.internetAccess ? "bridge" : "none") as NetworkMode,
cpuLimit: cfg.cpuLimit,
memoryLimitMB: cfg.memoryLimitMB,
diskLimitMB: cfg.diskLimitMB,
autoInstallPreset: cfg.autoInstallPreset,
portForwards: cfg.portForwards,
hostMountPath: cfg.hostMountPath,
wslDistroName: cfg.wslDistroName,
persistenceMode: cfg.persistenceMode,
});
}
export async function toolsProvider(ctl: PluginController) {
const cfg = readConfig(ctl);
turnBudget.maxCalls = cfg.maxToolCallsPerTurn;
const executeTool = tool({
name: "Execute",
description:
`Run a shell command on your dedicated Linux computer.\n\n` +
`IMPORTANT: This runs in a persistent shell session — state is preserved between calls.\n` +
`• cd, export, source, nvm use, conda activate — all persist across commands\n` +
`• You are always in the same shell; no need to repeat setup\n` +
`• Use pwd to check where you are, env to see variables\n\n` +
`This is a real isolated Linux container. You can install packages, ` +
`compile code, run scripts, manage files, start services, etc.\n\n` +
`TIPS:\n` +
`• Chain with && or ; as usual\n` +
`• Use 2>&1 to capture stderr\n` +
`• Background long tasks with ExecuteBackground instead of &\n` +
`• Install packages with apt-get (Ubuntu/Debian) or apk (Alpine)`,
parameters: {
command: z.string().min(1).max(8_000).describe("Shell command to execute. Supports pipes, redirects, chaining."),
timeout: z.number().int().min(1).max(MAX_TIMEOUT_SECONDS).optional()
.describe(`Timeout in seconds (default: ${cfg.commandTimeout}, max: ${MAX_TIMEOUT_SECONDS}). Increase for long operations like package installs.`),
workdir: z.string().optional().describe(`Working directory (default: ${CONTAINER_WORKDIR}).`),
},
implementation: async ({ command, timeout, workdir }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
if (cfg.strictSafety) {
const check = checkCommand(command, true);
if (!check.allowed) {
warn(check.reason!);
return { budget: budgetStatus(), error: check.reason, exitCode: -1 };
}
}
try {
await ensureContainer(cfg, status);
status(`Running: ${command.length > 80 ? command.slice(0, 77) + "…" : command}`);
const result = await engine.exec(command, timeout ?? cfg.commandTimeout, cfg.maxOutputSize, workdir);
if (result.timedOut) warn(`Command timed out after ${timeout ?? cfg.commandTimeout}s`);
if (result.truncated) status("Output was large — showing head and tail (use ReadFile to see full content)");
const hint = result.timedOut
? classifyError("timed out", { command }).hint
: result.exitCode !== 0 && result.stderr
? classifyError(result.stderr, { command }).hint
: undefined;
return {
budget: budgetStatus(),
exitCode: result.exitCode,
stdout: result.stdout || "(no output)",
stderr: result.stderr || "",
timedOut: result.timedOut,
durationMs: result.durationMs,
truncated: result.truncated,
...(hint ? { hint } : {}),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { command });
warn(error);
return { budget: budgetStatus(), error, hint, exitCode: -1 };
}
},
});
const writeFileTool = tool({
name: "WriteFile",
description:
`Create or overwrite a complete file inside the computer.\n\n` +
`Use for new files or when replacing the entire content. ` +
`For editing existing files, prefer StrReplace — ` +
`it is faster and uses far less context. ` +
`Parent directories are created automatically.`,
parameters: {
path: z.string().min(1).max(500).describe(`File path inside the container. Relative paths resolve to ${CONTAINER_WORKDIR}.`),
content: z.string().max(MAX_FILE_WRITE_BYTES).describe("File content to write."),
makeExecutable: z.boolean().optional().describe("Set the executable bit (chmod +x) after writing."),
},
implementation: async ({ path: filePath, content, makeExecutable }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
const dir = filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : null;
if (dir) await engine.exec(`mkdir -p '${dir.replace(/'/g, "'\\''")}'`, 5);
status(`Writing: ${filePath}`);
await engine.writeFile(filePath, content);
if (makeExecutable) await engine.exec(`chmod +x '${filePath.replace(/'/g, "'\\''")}'`, 5);
return {
budget: budgetStatus(),
written: true,
path: filePath,
bytesWritten: Buffer.byteLength(content, "utf-8"),
executable: makeExecutable ?? false,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath });
warn(error);
return { budget: budgetStatus(), error, hint, written: false };
}
},
});
const appendFileTool = tool({
name: "AppendFile",
description:
`Append content to an existing file inside the computer.\n\n` +
`Use this when you want to add lines to a log, config, or script ` +
`without reading and rewriting the whole file. ` +
`Creates the file if it does not exist.`,
parameters: {
path: z.string().min(1).max(500).describe("File path inside the container."),
content: z.string().max(MAX_FILE_WRITE_BYTES).describe("Content to append (added verbatim at the end of the file)."),
},
implementation: async ({ path: filePath, content }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Appending to: ${filePath}`);
await engine.appendFile(filePath, content);
return {
budget: budgetStatus(),
appended: true,
path: filePath,
bytesAppended: Buffer.byteLength(content, "utf-8"),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath });
warn(error);
return { budget: budgetStatus(), error, hint, appended: false };
}
},
});
const readFileTool = tool({
name: "ReadFile",
description:
`Read a file from the computer, optionally limited to a line range.\n\n` +
`Always read a file before editing it with StrReplace. ` +
`For large files use startLine/endLine to read only the section you need — ` +
`this keeps context short. Binary files may not display correctly.`,
parameters: {
path: z.string().min(1).max(500).describe("File path inside the container."),
startLine: z.number().int().min(1).optional().describe("First line to return (1-based, inclusive)."),
endLine: z.number().int().min(1).optional().describe("Last line to return (1-based, inclusive). Requires startLine."),
},
implementation: async ({ path: filePath, startLine, endLine }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Reading: ${filePath}`);
const { content, totalLines } = await engine.readFile(filePath, MAX_FILE_READ_BYTES, startLine, endLine);
return {
budget: budgetStatus(),
path: filePath,
content,
totalLines,
lineRange: startLine ? { from: startLine, to: endLine ?? totalLines } : undefined,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath });
warn(error);
return { budget: budgetStatus(), error, hint, path: filePath };
}
},
});
const strReplaceTool = tool({
name: "StrReplace",
description:
`Replace exact strings in a file — the preferred way to edit existing files.\n\n` +
`Accepts a single replacement or an array of replacements applied in order.\n` +
`The file is read and written exactly once regardless of how many replacements you provide.\n\n` +
`Rules:\n` +
`• Each oldStr must match the file exactly (whitespace, indentation included)\n` +
`• Each oldStr must appear exactly once — include surrounding lines to make it unique\n` +
`• Always ReadFile first to see the current content\n` +
`• To delete a section, set newStr to an empty string`,
parameters: {
path: z.string().min(1).max(500).describe("File path inside the container."),
replacements: z
.array(z.object({
oldStr: z.string().min(1).describe("The exact string to find. Must be unique in the file."),
newStr: z.string().describe("The replacement string. Use empty string to delete."),
}))
.min(1)
.describe("One or more find-and-replace pairs, applied in order."),
},
implementation: async ({ path: filePath, replacements }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Editing: ${filePath} (${replacements.length} replacement${replacements.length > 1 ? "s" : ""})`);
const { replacements: count } = await engine.strReplaceInFile(filePath, replacements);
return {
budget: budgetStatus(),
edited: true,
path: filePath,
replacements: count,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath });
warn(error);
return { budget: budgetStatus(), error, hint, edited: false };
}
},
});
const insertLinesTool = tool({
name: "InsertLines",
description:
`Insert lines into a file at a specific position.\n\n` +
`Use this to add new content without replacing existing content. ` +
`afterLine=0 prepends to the file. afterLine equal to the total line count appends.`,
parameters: {
path: z.string().min(1).max(500).describe("File path inside the container."),
afterLine: z.number().int().min(0).describe("Insert after this line number (1-based). Use 0 to insert at the top."),
content: z.string().describe("The lines to insert."),
},
implementation: async ({ path: filePath, afterLine, content }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Inserting into: ${filePath}`);
await engine.insertLinesInFile(filePath, afterLine, content);
return { budget: budgetStatus(), inserted: true, path: filePath, afterLine };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath });
warn(error);
return { budget: budgetStatus(), error, hint, inserted: false };
}
},
});
const listDirTool = tool({
name: "ListDirectory",
description:
`List files and directories inside the computer.\n\n` +
`Returns a structured directory listing with file types, sizes, and permissions.`,
parameters: {
path: z.string().optional().describe(`Directory path (default: ${CONTAINER_WORKDIR}).`),
showHidden: z.boolean().optional().describe("Include hidden files (dotfiles). Default: false."),
recursive: z.boolean().optional().describe("List recursively up to 3 levels deep. Default: false."),
},
implementation: async ({ path: dirPath, showHidden, recursive }, { status }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
const target = dirPath ?? CONTAINER_WORKDIR;
const hidden = showHidden ? "-a" : "";
let cmd: string;
if (recursive) {
cmd = `find '${target.replace(/'/g, "'\\''")}' -maxdepth 3 ${showHidden ? "" : "-not -path '*/.*'"} -printf '%y %s %T@ %p\\n' 2>/dev/null | head -200`;
} else {
cmd = `ls -l ${hidden} --time-style=long-iso '${target.replace(/'/g, "'\\''")}' 2>/dev/null || ls -l ${hidden} '${target.replace(/'/g, "\\'")}'`;
}
status(`Listing: ${target}`);
const result = await engine.exec(cmd, 10);
if (result.exitCode !== 0) {
return {
...classifyError(result.stderr || "Directory not found", { filePath: target }),
budget: budgetStatus(),
path: target,
};
}
return { budget: budgetStatus(), path: target, listing: result.stdout, recursive: recursive ?? false };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
return { budget: budgetStatus(), error, hint };
}
},
});
const moveFileTool = tool({
name: "MoveFile",
description:
`Move or rename a file or directory inside the computer.\n\n` +
`Parent directories of the destination are created automatically. ` +
`Use this instead of Execute + mv for cleaner error handling.`,
parameters: {
source: z.string().min(1).max(500).describe("Source path inside the container."),
destination: z.string().min(1).max(500).describe("Destination path inside the container."),
},
implementation: async ({ source, destination }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Moving: ${source} → ${destination}`);
await engine.moveFile(source, destination);
return { budget: budgetStatus(), moved: true, source, destination };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath: source });
warn(error);
return { budget: budgetStatus(), error, hint, moved: false };
}
},
});
const copyFileTool = tool({
name: "CopyFile",
description:
`Copy a file or directory inside the computer.\n\n` +
`Directories are copied recursively. ` +
`Parent directories of the destination are created automatically.`,
parameters: {
source: z.string().min(1).max(500).describe("Source path inside the container."),
destination: z.string().min(1).max(500).describe("Destination path inside the container."),
},
implementation: async ({ source, destination }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Copying: ${source} → ${destination}`);
await engine.copyFile(source, destination);
return { budget: budgetStatus(), copied: true, source, destination };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath: source });
warn(error);
return { budget: budgetStatus(), error, hint, copied: false };
}
},
});
const searchInFilesTool = tool({
name: "SearchInFiles",
description:
`Search for a text pattern across files inside the computer — like IDE "Find in Project".\n\n` +
`Returns matching lines in file:line:content format. ` +
`Automatically excludes .git, node_modules, and .cache directories.\n\n` +
`Use this instead of Execute + grep when you want clean error handling and ` +
`consistent output limits. Supports regex patterns.`,
parameters: {
pattern: z.string().min(1).describe("Search pattern (supports regular expressions)."),
directory: z.string().optional().describe(`Directory to search in (default: ${CONTAINER_WORKDIR}).`),
ignoreCase: z.boolean().optional().describe("Case-insensitive search. Default: false."),
glob: z.string().optional().describe("Limit search to files matching a glob pattern, e.g. '*.ts' or '*.py'."),
maxResults: z.number().int().min(1).max(500).optional().describe("Maximum number of matches to return (default: 200)."),
},
implementation: async ({ pattern, directory, ignoreCase, glob, maxResults }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
const dir = directory ?? CONTAINER_WORKDIR;
status(`Searching for "${pattern}" in ${dir}…`);
const result = await engine.searchInFiles(pattern, dir, { ignoreCase, glob, maxResults });
return {
budget: budgetStatus(),
pattern,
directory: dir,
matches: result.matches,
matchCount: result.count,
truncated: result.truncated,
...(result.truncated
? { hint: `Results truncated at ${result.count}. Refine your pattern or use a glob filter to narrow results.` }
: {}),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
warn(error);
return { budget: budgetStatus(), error, hint };
}
},
});
const setEnvVarTool = tool({
name: "SetEnvVar",
description:
`Set a persistent environment variable inside the computer.\n\n` +
`The variable is written to ~/.bashrc so it survives shell resets (ResetShell) ` +
`and persists across sessions in persistent mode. ` +
`It is also exported immediately in the current shell session.\n\n` +
`Use this for API keys, configuration values, PATH additions, etc. ` +
`Overwrites any previous value for the same key.`,
parameters: {
key: z.string().min(1).max(100).describe("Variable name (must match [A-Za-z_][A-Za-z0-9_]*)."),
value: z.string().max(4_000).describe("Variable value."),
},
implementation: async ({ key, value }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Setting env var: ${key}`);
await engine.setEnvVar(key, value);
return {
budget: budgetStatus(),
set: true,
key,
message: `${key} is now set and will persist across shell resets.`,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
warn(error);
return { budget: budgetStatus(), error, hint, set: false };
}
},
});
const uploadFileTool = tool({
name: "UploadFile",
description:
`Transfer a file from the user's host computer into the container.\n\n` +
`Use this when the user shares a file they want you to work with.`,
parameters: {
hostPath: z.string().min(1).max(1000).describe("Absolute path to the file on the user's host machine."),
containerPath: z.string().optional().describe(`Destination path inside the container (default: ${CONTAINER_WORKDIR}/<filename>).`),
},
implementation: async ({ hostPath, containerPath }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
const filename = hostPath.split("/").pop() ?? hostPath.split("\\").pop() ?? "file";
const dest = containerPath ?? `${CONTAINER_WORKDIR}/${filename}`;
status(`Uploading: ${filename} → ${dest}`);
await engine.copyToContainer(hostPath, dest);
return { budget: budgetStatus(), uploaded: true, hostPath, containerPath: dest };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath: hostPath });
warn(error);
return { budget: budgetStatus(), error, hint, uploaded: false };
}
},
});
const downloadFileTool = tool({
name: "DownloadFile",
description:
`Transfer a file from the container to the user's host computer.\n\n` +
`Use this to give the user a file you created or modified inside the computer.`,
parameters: {
containerPath: z.string().min(1).max(500).describe("Path to the file inside the container."),
hostPath: z.string().optional().describe("Destination path on the host. Default: user's home directory + filename."),
},
implementation: async ({ containerPath, hostPath }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
const filename = containerPath.split("/").pop() ?? "file";
const dest = hostPath ?? pathJoin(homedir(), filename);
status(`Downloading: ${containerPath} → ${dest}`);
await engine.copyFromContainer(containerPath, dest);
return { budget: budgetStatus(), downloaded: true, containerPath, hostPath: dest };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { filePath: containerPath });
warn(error);
return { budget: budgetStatus(), error, hint, downloaded: false };
}
},
});
const executeBackgroundTool = tool({
name: "ExecuteBackground",
description:
`Run a command in the background and get a handle to check its output later.\n\n` +
`Use this for long-running tasks that shouldn't block: servers, watchers, ` +
`build processes, test suites, etc.\n\n` +
`Returns a handleId. Use ReadProcessLogs with that handleId to stream output. ` +
`Use KillBackground to stop it. Background processes survive across turns.`,
parameters: {
command: z.string().min(1).describe("Shell command to run in the background."),
timeout: z.number().int().min(5).max(3600).optional().describe("Max seconds before the process is killed. Default: 300."),
},
implementation: async ({ command, timeout }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status(`Starting background: ${command.slice(0, 60)}${command.length > 60 ? "…" : ""}`);
const { handleId, pid } = await engine.execBackground(command, timeout ?? 300);
return {
budget: budgetStatus(),
started: true,
handleId,
pid,
message: `Process started. Use ReadProcessLogs(handleId: ${handleId}) to check output. Use KillBackground(handleId: ${handleId}) to stop it.`,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg, { command });
warn(error);
return { budget: budgetStatus(), error, hint, started: false };
}
},
});
const readProcessLogsTool = tool({
name: "ReadProcessLogs",
description:
`Read buffered output from a background process started with ExecuteBackground.\n\n` +
`Pass the nextOffset from the previous call as fromOffset to receive only ` +
`new output since the last read — avoids seeing duplicate lines when polling.\n\n` +
`Returns stdout, stderr, whether the process is still running, and its exit code if done.`,
parameters: {
handleId: z.number().int().describe("The handleId returned by ExecuteBackground."),
fromOffset: z.number().int().min(0).optional().describe("Byte offset for incremental reads. Use the nextOffset from the previous call. Omit on first read."),
},
implementation: async ({ handleId, fromOffset }, { warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
const logs = engine.readBgLogs(handleId, MAX_FILE_READ_BYTES, fromOffset ?? 0);
if (!logs.found) {
return {
budget: budgetStatus(),
error: `No process found with handleId ${handleId}.`,
hint: "handleIds are only valid within the current LM Studio session.",
};
}
return {
budget: budgetStatus(),
handleId,
stdout: logs.stdout,
stderr: logs.stderr,
running: !logs.done,
exitCode: logs.exitCode,
nextOffset: logs.nextOffset,
};
},
});
const killBackgroundTool = tool({
name: "KillBackground",
description:
`Stop a background process started with ExecuteBackground.\n\n` +
`Sends SIGTERM first; if the process doesn't exit within 2 seconds, sends SIGKILL. ` +
`You can still read its final output with ReadProcessLogs after killing it.`,
parameters: {
handleId: z.number().int().describe("The handleId returned by ExecuteBackground."),
},
implementation: async ({ handleId }, { warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
const result = engine.killBgProcess(handleId);
if (!result.found) {
return {
budget: budgetStatus(),
error: `No process found with handleId ${handleId}.`,
hint: "handleIds are only valid within the current LM Studio session.",
};
}
if (result.alreadyDone) {
return {
budget: budgetStatus(),
killed: false,
handleId,
message: "Process had already finished.",
};
}
return {
budget: budgetStatus(),
killed: true,
handleId,
message: "SIGTERM sent. Process will be SIGKILL'd in 2 seconds if still running.",
};
},
});
const killProcessTool = tool({
name: "KillProcess",
description:
`Kill a process inside the container by PID.\n\n` +
`Use ComputerStatus with showProcesses: true to find PIDs. ` +
`Sends SIGTERM by default; use signal: "SIGKILL" to force-kill immediately.`,
parameters: {
pid: z.number().int().min(1).describe("Process ID to kill."),
signal: z.enum(["SIGTERM", "SIGKILL", "SIGINT", "SIGHUP"]).optional()
.describe("Signal to send (default: SIGTERM)."),
},
implementation: async ({ pid, signal }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
const ok = await engine.killProcess(pid, signal ?? "SIGTERM");
if (!ok) {
warn(`Failed to send ${signal ?? "SIGTERM"} to PID ${pid}`);
return {
budget: budgetStatus(),
killed: false,
pid,
hint: "Process may not exist or you may not have permission. Use ComputerStatus with showProcesses: true to verify.",
};
}
return {
budget: budgetStatus(),
killed: true,
pid,
signal: signal ?? "SIGTERM",
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
warn(error);
return { budget: budgetStatus(), error, hint, killed: false };
}
},
});
const statusTool = tool({
name: "ComputerStatus",
description:
`Get information about the computer: OS, installed tools, disk/memory usage, ` +
`running processes, network status, and resource limits.\n\n` +
`Also shows the per-turn tool call budget and any active background processes.`,
parameters: {
showProcesses: z.boolean().optional().describe("Include a list of running processes. Default: false."),
},
implementation: async ({ showProcesses }, { status, warn }) => {
const budgetError = consumeBudget();
if (budgetError) return { budget: budgetStatus(), error: budgetError };
try {
await ensureContainer(cfg, status);
status("Gathering system info…");
const [envInfo, containerInfo] = await Promise.all([
engine.getEnvironmentInfo(cfg.internetAccess, cfg.diskLimitMB),
engine.getContainerInfo(),
]);
let processes: any[] | undefined;
if (showProcesses) {
const procs = await engine.listProcesses();
processes = procs.map((p) => ({
pid: p.pid,
user: p.user,
cpu: p.cpu + "%",
memory: p.memory + "%",
command: p.command,
}));
}
const bgProcesses = engine.listBgProcesses();
const activeBg = bgProcesses.filter((p) => p.running);
return {
budget: budgetStatus(),
container: {
id: containerInfo.id,
state: containerInfo.state,
image: containerInfo.image,
cpuUsage: containerInfo.cpuUsage,
memoryUsage: containerInfo.memoryUsage,
networkMode: containerInfo.networkMode,
},
environment: envInfo,
config: {
internetAccess: cfg.internetAccess,
persistenceMode: cfg.persistenceMode,
cpuLimit: cfg.cpuLimit > 0 ? `${cfg.cpuLimit} cores` : "unlimited",
memoryLimit: `${cfg.memoryLimitMB} MB`,
commandTimeout: `${cfg.commandTimeout}s`,
},
...(activeBg.length > 0
? {
backgroundProcesses: activeBg.map((p) => ({
handleId: p.handleId,
command: p.command,
runtimeSecs: p.runtimeSecs,
})),
}
: {}),
...(processes ? { processes } : {}),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
warn(error);
return { budget: budgetStatus(), error, hint };
}
},
});
const restartComputerTool = tool({
name: "RestartComputer",
description:
`Stop and restart the container without wiping any data.\n\n` +
`Use this when:\n` +
`- A runaway process is consuming too many resources\n` +
`- The container feels sluggish or unresponsive\n` +
`- You want a clean shell session but keep installed packages and files\n\n` +
`Faster than RebuildComputer. All files and installed packages are preserved. ` +
`Background processes will be stopped.`,
parameters: {},
implementation: async (_, { status, warn }) => {
try {
status("Restarting computer…");
await engine.restartContainer();
return {
budget: budgetStatus(),
restarted: true,
message: "Container restarted. Files and packages are intact.",
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
warn(error);
return { budget: budgetStatus(), error, hint, restarted: false };
}
},
});
const rebuildTool = tool({
name: "RebuildComputer",
description:
`Destroy the current container and rebuild it from scratch using the current settings.\n\n` +
`Use this when:\n` +
`- Internet access is not working after toggling the setting\n` +
`- The container is broken or in a bad state\n` +
`- Settings like base image or network were changed and need to take effect\n\n` +
`WARNING: All data inside the container will be lost. Files in the shared folder are safe.`,
parameters: {
confirm: z.boolean().describe("Must be true to confirm you want to destroy and rebuild the container."),
},
implementation: async ({ confirm }, { status, warn }) => {
if (!confirm) {
return { budget: budgetStatus(), error: "Set confirm=true to proceed with rebuild." };
}
try {
status("Stopping and removing existing container…");
await engine.destroyContainer();
status("Rebuilding container with current settings…");
await engine.ensureReady({
image: cfg.baseImage as ContainerImage,
network: (cfg.internetAccess ? "bridge" : "none") as NetworkMode,
cpuLimit: cfg.cpuLimit,
memoryLimitMB: cfg.memoryLimitMB,
diskLimitMB: cfg.diskLimitMB,
autoInstallPreset: cfg.autoInstallPreset,
portForwards: cfg.portForwards,
hostMountPath: cfg.hostMountPath,
wslDistroName: cfg.wslDistroName,
persistenceMode: cfg.persistenceMode,
});
const envInfo = await engine.getEnvironmentInfo(cfg.internetAccess, cfg.diskLimitMB);
return {
budget: budgetStatus(),
rebuilt: true,
os: envInfo.os,
internetAccess: cfg.internetAccess,
message: "Container rebuilt successfully with current settings.",
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const { error, hint } = classifyError(msg);
warn(error);
return { budget: budgetStatus(), error, hint, rebuilt: false };
}
},
});
const resetShellTool = tool({
name: "ResetShell",
description:
`Reset the persistent shell session back to a clean state.\n\n` +
`Use this when:\n` +
`• The shell is in a broken state (stuck command, corrupted env)\n` +
`• You want to start fresh without rebuilding the whole container\n` +
`• Environment variables or working directory are in an unexpected state\n\n` +
`This does NOT wipe the container filesystem — files, installed packages, ` +
`and running background processes are all preserved. ` +
`Persistent env vars set with SetEnvVar will be automatically re-applied from ~/.bashrc.`,
parameters: {},
implementation: async (_, { status }) => {
engine.resetShellSession();
status("Shell session reset.");
return {
budget: budgetStatus(),
reset: true,
message: `Shell session reset. Working directory is back to ${CONTAINER_WORKDIR} with a clean environment. Persistent env vars from SetEnvVar will be re-applied automatically.`,
};
},
});
return [
executeTool,
writeFileTool,
appendFileTool,
readFileTool,
strReplaceTool,
insertLinesTool,
listDirTool,
moveFileTool,
copyFileTool,
searchInFilesTool,
setEnvVarTool,
uploadFileTool,
downloadFileTool,
executeBackgroundTool,
readProcessLogsTool,
killBackgroundTool,
killProcessTool,
statusTool,
restartComputerTool,
rebuildTool,
resetShellTool,
];
}