src / workspace.ts
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
/** Error whose message is safe to surface to the model or user. */
export class WorkspaceError extends Error {}
/** OS-appropriate example path shown to the model in tool descriptions and reminders. */
export const EXAMPLE_PATH =
process.platform === "win32" ? "D:/AI stuff/MyProject" : "/home/me/my-project";
let root: string | null = null;
/**
* LM Studio may run tool calls and prompt hooks in a fresh plugin process (or reload the
* plugin between turns), which would wipe in-memory state. Mirror the workspace to a small
* file so any process can pick it back up. Shared across chats; set_workspace overwrites it.
*/
const STATE_FILE = path.join(os.tmpdir(), "lmstudio-code-agent-workspace.txt");
function persistRoot(abs: string): void {
try {
fs.writeFileSync(STATE_FILE, abs, "utf8");
} catch {
// Best effort; in-memory state still works within this process.
}
}
/** Current root: in-memory if set, otherwise restored from the state file if still valid. */
function currentRoot(): string | null {
if (root !== null) return root;
try {
const saved = fs.readFileSync(STATE_FILE, "utf8").trim();
if (saved && fs.statSync(saved).isDirectory()) {
root = saved;
return root;
}
} catch {
// No saved state, or the folder no longer exists.
}
return null;
}
function norm(p: string): string {
return process.platform === "win32" ? p.toLowerCase() : p;
}
export function hasWorkspace(): boolean {
return currentRoot() !== null;
}
/** Returns the workspace root, or throws if none is set yet. */
export function getWorkspace(): string {
const r = currentRoot();
if (r === null) {
throw new WorkspaceError(
"No working folder set yet. Ask the user for their project folder and call set_workspace first."
);
}
return r;
}
/**
* Strip surrounding whitespace and matching quotes/backticks models often add.
* On Windows, also repair escape damage: when a model sends "D:\temp" with single
* backslashes, JSON parsing turns "\t" into a tab, "\b" into a backspace, etc.
*/
export function cleanPathInput(p: string): string {
let s = p.trim();
for (let i = 0; i < 2; i++) {
const n = s.length;
if (n >= 2) {
const a = s[0];
const b = s[n - 1];
if ((a === '"' && b === '"') || (a === "'" && b === "'") || (a === "`" && b === "`")) {
s = s.slice(1, n - 1).trim();
} else break;
}
}
if (process.platform === "win32") {
s = s
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\f/g, "\\f")
.replace(/\x08/g, "\\b");
}
return s;
}
/** Suggestion hint: nearest existing ancestor folder plus up to 10 of its subfolders. */
export function suggestFolders(abs: string): string {
let dir = path.dirname(abs);
for (let i = 0; i < 8; i++) {
if (!fs.existsSync(dir)) {
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
continue;
}
try {
const names = fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()
.slice(0, 10);
if (names.length === 0) return `\nClosest existing folder is ${dir} (no subfolders).`;
return `\nClosest existing folder is ${dir}. Subfolders:\n${names.join("\n")}`;
} catch {
return "";
}
}
return "";
}
/** Validates, canonicalizes, and stores the workspace root. Returns the absolute path. */
export function setWorkspace(p: string): string {
const clean = cleanPathInput(p);
if (clean === "" || clean.includes("\0")) {
throw new WorkspaceError("Invalid folder path.");
}
const abs = path.resolve(clean);
let st: fs.Stats;
try {
st = fs.statSync(abs);
} catch {
throw new WorkspaceError(
`Folder not found: ${abs}. Check the exact spelling and case.${suggestFolders(abs)}`
);
}
if (!st.isDirectory()) {
throw new WorkspaceError(`Not a folder: ${abs}`);
}
root = abs;
persistRoot(abs);
return abs;
}
/**
* Resolves `rel` against the workspace root and guarantees it stays inside.
* Accepts relative paths (joined under root) or absolute paths (checked for containment).
*/
export function resolveSafe(rel: string): string {
const r = getWorkspace();
rel = cleanPathInput(rel);
if (rel === "" || rel.includes("\0")) {
throw new WorkspaceError("Invalid path.");
}
// Models often use "/" or "\" to mean "the project root".
if (rel === "/" || rel === "\\") return r;
const resolved = path.resolve(r, rel);
const relToRoot = path.relative(norm(r), norm(resolved));
const escapes =
relToRoot === ".." || relToRoot.startsWith(".." + path.sep) || path.isAbsolute(relToRoot);
if (escapes) {
// Recovery: the model gave a path beside the workspace, e.g. "D:\AI stuff\Foo.csproj"
// when the root is "D:\AI stuff\Foo". Use the file inside the workspace if it exists.
if (path.isAbsolute(rel) && norm(path.dirname(resolved)) === norm(path.dirname(r))) {
const candidate = path.join(r, path.basename(resolved));
if (fs.existsSync(candidate)) return candidate;
}
throw new WorkspaceError(
`Path escapes the working folder: ${rel}. The working folder is ${r}. ` +
`Use paths relative to it, e.g. "src/app.ts" or just the file name.`
);
}
return resolved;
}src / workspace.ts
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
/** Error whose message is safe to surface to the model or user. */
export class WorkspaceError extends Error {}
/** OS-appropriate example path shown to the model in tool descriptions and reminders. */
export const EXAMPLE_PATH =
process.platform === "win32" ? "D:/AI stuff/MyProject" : "/home/me/my-project";
let root: string | null = null;
/**
* LM Studio may run tool calls and prompt hooks in a fresh plugin process (or reload the
* plugin between turns), which would wipe in-memory state. Mirror the workspace to a small
* file so any process can pick it back up. Shared across chats; set_workspace overwrites it.
*/
const STATE_FILE = path.join(os.tmpdir(), "lmstudio-code-agent-workspace.txt");
function persistRoot(abs: string): void {
try {
fs.writeFileSync(STATE_FILE, abs, "utf8");
} catch {
// Best effort; in-memory state still works within this process.
}
}
/** Current root: in-memory if set, otherwise restored from the state file if still valid. */
function currentRoot(): string | null {
if (root !== null) return root;
try {
const saved = fs.readFileSync(STATE_FILE, "utf8").trim();
if (saved && fs.statSync(saved).isDirectory()) {
root = saved;
return root;
}
} catch {
// No saved state, or the folder no longer exists.
}
return null;
}
function norm(p: string): string {
return process.platform === "win32" ? p.toLowerCase() : p;
}
export function hasWorkspace(): boolean {
return currentRoot() !== null;
}
/** Returns the workspace root, or throws if none is set yet. */
export function getWorkspace(): string {
const r = currentRoot();
if (r === null) {
throw new WorkspaceError(
"No working folder set yet. Ask the user for their project folder and call set_workspace first."
);
}
return r;
}
/**
* Strip surrounding whitespace and matching quotes/backticks models often add.
* On Windows, also repair escape damage: when a model sends "D:\temp" with single
* backslashes, JSON parsing turns "\t" into a tab, "\b" into a backspace, etc.
*/
export function cleanPathInput(p: string): string {
let s = p.trim();
for (let i = 0; i < 2; i++) {
const n = s.length;
if (n >= 2) {
const a = s[0];
const b = s[n - 1];
if ((a === '"' && b === '"') || (a === "'" && b === "'") || (a === "`" && b === "`")) {
s = s.slice(1, n - 1).trim();
} else break;
}
}
if (process.platform === "win32") {
s = s
.replace(/\t/g, "\\t")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\f/g, "\\f")
.replace(/\x08/g, "\\b");
}
return s;
}
/** Suggestion hint: nearest existing ancestor folder plus up to 10 of its subfolders. */
export function suggestFolders(abs: string): string {
let dir = path.dirname(abs);
for (let i = 0; i < 8; i++) {
if (!fs.existsSync(dir)) {
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
continue;
}
try {
const names = fs
.readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()
.slice(0, 10);
if (names.length === 0) return `\nClosest existing folder is ${dir} (no subfolders).`;
return `\nClosest existing folder is ${dir}. Subfolders:\n${names.join("\n")}`;
} catch {
return "";
}
}
return "";
}
/** Validates, canonicalizes, and stores the workspace root. Returns the absolute path. */
export function setWorkspace(p: string): string {
const clean = cleanPathInput(p);
if (clean === "" || clean.includes("\0")) {
throw new WorkspaceError("Invalid folder path.");
}
const abs = path.resolve(clean);
let st: fs.Stats;
try {
st = fs.statSync(abs);
} catch {
throw new WorkspaceError(
`Folder not found: ${abs}. Check the exact spelling and case.${suggestFolders(abs)}`
);
}
if (!st.isDirectory()) {
throw new WorkspaceError(`Not a folder: ${abs}`);
}
root = abs;
persistRoot(abs);
return abs;
}
/**
* Resolves `rel` against the workspace root and guarantees it stays inside.
* Accepts relative paths (joined under root) or absolute paths (checked for containment).
*/
export function resolveSafe(rel: string): string {
const r = getWorkspace();
rel = cleanPathInput(rel);
if (rel === "" || rel.includes("\0")) {
throw new WorkspaceError("Invalid path.");
}
// Models often use "/" or "\" to mean "the project root".
if (rel === "/" || rel === "\\") return r;
const resolved = path.resolve(r, rel);
const relToRoot = path.relative(norm(r), norm(resolved));
const escapes =
relToRoot === ".." || relToRoot.startsWith(".." + path.sep) || path.isAbsolute(relToRoot);
if (escapes) {
// Recovery: the model gave a path beside the workspace, e.g. "D:\AI stuff\Foo.csproj"
// when the root is "D:\AI stuff\Foo". Use the file inside the workspace if it exists.
if (path.isAbsolute(rel) && norm(path.dirname(resolved)) === norm(path.dirname(r))) {
const candidate = path.join(r, path.basename(resolved));
if (fs.existsSync(candidate)) return candidate;
}
throw new WorkspaceError(
`Path escapes the working folder: ${rel}. The working folder is ${r}. ` +
`Use paths relative to it, e.g. "src/app.ts" or just the file name.`
);
}
return resolved;
}