Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 0 additions & 55 deletions .agents/skills/agent-browser/SKILL.md

This file was deleted.

48 changes: 48 additions & 0 deletions lib/open-file-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { access, realpath, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, win32 } from "node:path";

export function cleanOpenPath(value: unknown) {
if (typeof value !== "string") return "";
return value.trim().replace(/^["']|["']$/g, "");
}

export function isPathInside(candidate: string, parent: string) {
const childRelative = relative(parent, candidate);
return (
childRelative === "" ||
(!!childRelative && !childRelative.startsWith("..") && !isAbsolute(childRelative))
);
}

function isExpectedFileSystemError(error: unknown) {
if (!error || typeof error !== "object" || !("code" in error)) return false;
return ["EACCES", "EBUSY", "ELOOP", "EMFILE", "ENOENT", "ENOTDIR", "EPERM"].includes(
String(error.code),
);
}

export async function resolveFileTarget(filePath: unknown, baseDirectory?: unknown) {
const target = cleanOpenPath(filePath);
if (!target || target.includes("\0")) return null;

const base = cleanOpenPath(baseDirectory);
const normalizedBase = base ? resolve(base) : null;
if (!normalizedBase) return null;

const targetAbsolute =
isAbsolute(target) || (process.platform === "win32" && win32.isAbsolute(target));
const resolved = targetAbsolute ? resolve(target) : resolve(normalizedBase, target);
if (!isPathInside(resolved, normalizedBase)) return null;

try {
const realBase = await realpath(normalizedBase);
const realResolved = await realpath(resolved);
if (!isPathInside(realResolved, realBase)) return null;
await access(realResolved);
if (!(await stat(realResolved)).isFile()) return null;
return realResolved;
} catch (error) {
if (!isExpectedFileSystemError(error)) throw error;
return null;
}
}
21 changes: 21 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createBackendSidecarController } from "./main/backend-sidecar.js";
import { broadcastToAllWindows } from "./lib/window-broadcast.js";
import { findFilesInDirectory } from "./server/services/file-search.js";
import { getHarnessInventories } from "./server/harness-inventory.js";
import { resolveFileTarget } from "./lib/open-file-target.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -483,6 +484,26 @@ ipcMain.handle("shell:openExternal", (_event, url) => {
if (isWebUrl(url)) void shell.openExternal(url);
});

// Electron main-process handlers. The browser-only web server registers the
// same channel names against its FakeIpcMain in server/shell-ipc-handlers.ts.
ipcMain.handle("shell:fileExists", async (_event, filePath, baseDirectory) => {
return !!(await resolveFileTarget(filePath, baseDirectory));
});

ipcMain.handle("shell:openFile", async (_event, filePath, baseDirectory) => {
const target = await resolveFileTarget(filePath, baseDirectory);
if (!target) return false;
const error = await shell.openPath(target);
return !error;
});

ipcMain.handle("shell:showFileInFolder", async (_event, filePath, baseDirectory) => {
const target = await resolveFileTarget(filePath, baseDirectory);
if (!target) return false;
shell.showItemInFolder(target);
return true;
});

// Open a directory in the system file browser
ipcMain.handle("shell:openInFileBrowser", (_event, dirPath, command = "") => {
if (typeof dirPath !== "string" || dirPath.length === 0) return;
Expand Down
8 changes: 5 additions & 3 deletions opencode-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1889,10 +1889,12 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) {
}
});

handleSessionOp("opencode:session:revert", async (conn, id, messageID, partID) =>
tagOpenCodeSession(await conn.revertSession(id, messageID, partID), conn.getDirectory()),
handleSessionOp(
"opencode:session:revert",
async (conn, id, messageID, partID, _directory, _workspaceId) =>
tagOpenCodeSession(await conn.revertSession(id, messageID, partID), conn.getDirectory()),
);
handleSessionOp("opencode:session:unrevert", async (conn, id) =>
handleSessionOp("opencode:session:unrevert", async (conn, id, _directory, _workspaceId) =>
tagOpenCodeSession(await conn.unrevertSession(id), conn.getDirectory()),
);

Expand Down
3 changes: 3 additions & 0 deletions preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ const electronAPI: ElectronAPI = {
},

openExternal: invoke("shell:openExternal"),
fileExists: invoke("shell:fileExists"),
openFile: invoke("shell:openFile"),
showFileInFolder: invoke("shell:showFileInFolder"),
updates: {
getState: async () => disabledUpdateState,
check: async () => disabledUpdateState,
Expand Down
11 changes: 9 additions & 2 deletions server/services/harness-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,18 +346,25 @@ export class HarnessService {

async revertSession(input: {
session: SessionRecord;
scope: HarnessScope;
messageId: string;
partId?: string;
}): Promise<unknown> {
return this.backendRpc(input.session.harnessId, "session:revert", [
input.session.rawId,
input.messageId,
input.partId,
input.scope.directory,
undefined,
]);
}

async unrevertSession(input: { session: SessionRecord }): Promise<unknown> {
return this.backendRpc(input.session.harnessId, "session:unrevert", [input.session.rawId]);
async unrevertSession(input: { session: SessionRecord; scope: HarnessScope }): Promise<unknown> {
return this.backendRpc(input.session.harnessId, "session:unrevert", [
input.session.rawId,
input.scope.directory,
undefined,
]);
}

async loadResources(input: {
Expand Down
36 changes: 31 additions & 5 deletions server/services/prompt-queue-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { QueueMode } from "../../src/lib/session-drafts.ts";
import { access, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { SelectedModel } from "../../src/types/electron.d.ts";
import type { BackendEventBus } from "./event-bus.ts";
import type { ProjectService } from "./project-service.ts";
Expand Down Expand Up @@ -90,7 +93,7 @@ export class PromptQueueService {
const created = await this.storage.createPromptQueueEntry({
sessionId: session.id,
harnessId: session.harnessId,
projectDirectory: await this.getProjectDirectory(session.projectId),
projectDirectory: await this.getProjectDirectory(session),
harnessSessionId: session.rawId,
text: input.text,
model: input.model,
Expand Down Expand Up @@ -271,10 +274,33 @@ export class PromptQueueService {
return sessions;
}

private async getProjectDirectory(projectId: string): Promise<string> {
const project = await this.projects.getProject(projectId);
if (!project) throw new Error("Project not found");
return project.canonicalPath || project.path;
private async getProjectDirectory(session: SessionRecord): Promise<string> {
const project = await this.projects.getProject(session.projectId);
if (project) return project.canonicalPath || project.path;

const metadataDirectory =
session.metadata && typeof session.metadata.directory === "string"
? session.metadata.directory.trim()
: "";
if (metadataDirectory) return metadataDirectory;

// Older Session records sometimes used the directory itself as projectId.
// Keep queue usable for those records instead of failing a running Session.
if (session.projectId.startsWith("/") || session.projectId.startsWith("~")) {
const legacyPath = session.projectId.startsWith("~")
? join(homedir(), session.projectId.slice(1))
: session.projectId;
try {
await access(legacyPath);
if ((await stat(legacyPath)).isDirectory()) {
return legacyPath;
}
} catch {
// Fall through to the normal missing-project error.
}
}

throw new Error("Project not found");
}

private async reindex(sessionId: string): Promise<PromptQueueEntryRecord[]> {
Expand Down
16 changes: 15 additions & 1 deletion server/services/session-lifecycle-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,18 @@ export async function forkSessionThroughHarness(input: {

export async function revertSessionThroughHarness(input: {
services: BackendServiceContext;
project: ProjectRecord;
session: SessionRecord;
messageId: string;
partId?: string;
}): Promise<SessionRecord | null> {
const runtimeSession = await input.services.harnesses.revertSession({
session: input.session,
scope: buildHarnessScope({
project: input.project,
harnessId: input.session.harnessId,
sessionId: input.session.id,
}),
messageId: input.messageId,
partId: input.partId,
});
Expand All @@ -115,9 +121,17 @@ export async function revertSessionThroughHarness(input: {

export async function unrevertSessionThroughHarness(input: {
services: BackendServiceContext;
project: ProjectRecord;
session: SessionRecord;
}): Promise<SessionRecord | null> {
const runtimeSession = await input.services.harnesses.unrevertSession({ session: input.session });
const runtimeSession = await input.services.harnesses.unrevertSession({
session: input.session,
scope: buildHarnessScope({
project: input.project,
harnessId: input.session.harnessId,
sessionId: input.session.id,
}),
});
if (!runtimeSession || typeof runtimeSession !== "object" || Array.isArray(runtimeSession)) {
return null;
}
Expand Down
25 changes: 25 additions & 0 deletions server/shell-ipc-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname } from "node:path";
import { homedir } from "node:os";
import type { BackendServiceContext } from "./services/index.ts";
import { getHarnessInventories } from "./harness-inventory.ts";
import { resolveFileTarget } from "../lib/open-file-target.ts";

interface IpcSender {
send(channel: string, data: unknown): void;
Expand Down Expand Up @@ -54,6 +56,12 @@ function openPath(path: string) {
else spawnDetached("xdg-open", [path]);
}

function showFileInFolder(path: string) {
if (process.platform === "darwin") spawnDetached("open", ["-R", path]);
else if (process.platform === "win32") spawnDetached("explorer.exe", [`/select,${path}`]);
else openPath(dirname(path));
}

async function runPicker(command: string[]) {
const [file, ...args] = command;
if (!file) return null;
Expand Down Expand Up @@ -189,6 +197,23 @@ export function registerShellIpcHandlers(input: {
ipcMain.handle("shell:openExternal", (_event, url) =>
openExternal(typeof url === "string" ? url : ""),
);
// Browser-only web-server handlers registered on FakeIpcMain. Electron's
// real main process registers equivalent channels in main.ts.
ipcMain.handle("shell:fileExists", async (_event, filePath, baseDirectory) => {
return !!(await resolveFileTarget(filePath, baseDirectory));
});
ipcMain.handle("shell:openFile", async (_event, filePath, baseDirectory) => {
const target = await resolveFileTarget(filePath, baseDirectory);
if (!target) return false;
openPath(target);
return true;
});
ipcMain.handle("shell:showFileInFolder", async (_event, filePath, baseDirectory) => {
const target = await resolveFileTarget(filePath, baseDirectory);
if (!target) return false;
showFileInFolder(target);
return true;
});
ipcMain.handle("shell:openInFileBrowser", (_event, dirPath, command = "") => {
const dir = typeof dirPath === "string" ? dirPath : "";
if (!dir) return;
Expand Down
3 changes: 2 additions & 1 deletion server/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,7 @@ async function handleSessionRequest(request: Request) {
}
const session = await revertSessionThroughHarness({
services,
project,
session: existing,
messageId: body.messageId,
partId: toOptionalString(body.partId, "partId"),
Expand All @@ -1333,7 +1334,7 @@ async function handleSessionRequest(request: Request) {

if (child === "unrevert") {
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 });
const session = await unrevertSessionThroughHarness({ services, session: existing });
const session = await unrevertSessionThroughHarness({ services, project, session: existing });
if (session) return Response.json({ ok: true, value: session });
return Response.json({ ok: true, value: true });
}
Expand Down
Loading
Loading