Skip to content
Merged
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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
Default to using Vite+ (`vp`) instead of raw runtime or package-manager commands.

- Use `vp dev` for development
- Use `pnpm run dev` for desktop development (Electron)
- Use `pnpm run dev:web` for web development (browser)
- Use `pnpm run start` / `pnpm run start:web` for production runs
- Use `vp check` for lint, format, and type checks
- Use `vp lint` for lint only
- Use `vp fmt` for format only
- Use `vp test` for tests
- Use `vp build` for production build
- Use `vp run <task>` for project tasks
- Use `vp exec <command>` for local binaries
- Use `vp node <file>` for Node.js scripts
- Use `node --experimental-strip-types <file>` for project TypeScript scripts (or `vp node` when Vite+ env shims are installed)
- Use `vp dlx <package> <command>` for one-off package binaries
- Use `vp cache` for task cache
- Use `pnpm install` to install dependencies
Expand All @@ -18,7 +20,7 @@ Default to using Vite+ (`vp`) instead of raw runtime or package-manager commands

## Development

Use Vite+ task runner and pnpm-managed deps.
Run the app with `pnpm run dev` (Electron) or `pnpm run dev:web` (browser). Use Vite+ (`vp`) for lint, format, typecheck, test, and build—not for choosing dev vs prod.

## Code quality

Expand Down
14 changes: 4 additions & 10 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,18 @@ pnpm install

## Development

Run the development app:

```bash
vp dev
```

Or run the browser version with backend API:

```bash
vp run dev:web
pnpm run dev # Electron (desktop)
pnpm run dev:web # Browser + local backend API
```

## Code Style

This project uses Vite+ tasks:

```bash
vp dev # development
pnpm run dev # desktop development
pnpm run dev:web # web development
vp lint # lint check
vp check # lint, format, and type checks
vp test # unit tests
Expand Down
21 changes: 7 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,12 @@ No manual config file needed. Connection settings live in UI. Pick a Harness, co

### Development

Run the development app:
| Goal | Command |
| ---------------------------------- | ------------------ |
| Desktop app (Electron, hot reload) | `pnpm run dev` |
| Web UI only (browser + API) | `pnpm run dev:web` |

```bash
vp dev
```

Run web app with local backend API (projects, git, Harnesses):

```bash
vp run dev:web
```

Open `http://127.0.0.1:3000`. Browser folder picker uses server paths. Set `OPENGUI_ALLOWED_ROOTS=/path/to/projects` to restrict browsable folders.
For `dev:web`, open the URL Vite prints in the terminal (default port is often 5173). Browser folder picker uses server paths. Set `OPENGUI_ALLOWED_ROOTS=/path/to/projects` to restrict browsable folders.

### Docker

Expand All @@ -142,13 +135,13 @@ vp build
Run Electron app in production mode:

```bash
vp run start
pnpm run start
```

Build and run web app in production mode:

```bash
vp run start:web
pnpm run start:web
```

For internet-facing deploys, keep OpenGUI bound to localhost and put Apache or another HTTPS reverse proxy in front.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ Use Vite+ (`vp`) for project tasks and pnpm for dependency changes:

```bash
pnpm install # install dependencies
vp dev # development
pnpm run dev # desktop development (Electron)
pnpm run dev:web # web development (browser)
vp check # lint, format, and type checks
vp lint # lint only
vp fmt # format only
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"scripts": {
"postinstall": "node ./scripts/ensure-electron.mjs",
"prepare": "vp config",
"dev:electron": "vp run dev",
"dev": "node --experimental-strip-types scripts/dev-desktop.ts",
"dev:web": "vp dev",
"build": "vp build",
"check": "vp check",
"check:fix": "vp check --fix",
Expand Down
2 changes: 1 addition & 1 deletion pi-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createServer as createNetServer } from "node:net";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { mkdir, open, readFile, unlink, writeFile } from "node:fs/promises";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import {
Expand Down
5 changes: 2 additions & 3 deletions dev.ts → scripts/dev-desktop.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Dev script - replaces concurrently + wait-on.
* Starts the Vite+ dev server, waits for it to be ready, then launches Electron.
* Kills both processes on exit.
* Desktop development: build Electron main/preload, run Vite + web backend, launch Electron.
* Use: pnpm run dev
*/

import { spawn, spawnSync } from "node:child_process";
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 @@ -336,18 +336,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
23 changes: 18 additions & 5 deletions server/services/prompt-queue-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,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 +271,23 @@ 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("~")) {
return session.projectId;
}

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
3 changes: 2 additions & 1 deletion server/web-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,7 @@ async function handleSessionRequest(request: Request) {
}
const session = await revertSessionThroughHarness({
services,
project,
session: existing,
messageId: body.messageId,
partId: toOptionalString(body.partId, "partId"),
Expand All @@ -1277,7 +1278,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
46 changes: 46 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MergeDialog } from "@/components/MergeDialog";
import { QueueList } from "@/components/QueueList";
import { UpdateDialog } from "@/components/UpdateDialog";
import { NoProjectConnected, NoSessionSelected } from "@/components/EmptyChatStates";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
Expand Down Expand Up @@ -40,6 +41,12 @@ import { SetupWizard } from "./components/SetupWizard";
import { TitleBar } from "./components/TitleBar";
import "./index.css";

function extractTerminalCommand(message: string | null) {
if (!message) return null;
const match = message.match(/\bRun\s+['"`]([^'"`]+)['"`]\s+in\s+(?:your\s+)?terminal/i);
return match?.[1]?.trim() || null;
}

function AppContent({
detachedProject,
suppressBootErrors,
Expand Down Expand Up @@ -75,6 +82,7 @@ function AppContent({
isLoadingMessages,
activeTargetDirectory,
sessionMeta,
sessionErrors,
} = useSessionState();
const { messages } = useMessages();
const { providers, selectedModel, providerDefaults } = useModelState();
Expand All @@ -93,6 +101,11 @@ function AppContent({
[bootLogs],
);
const activeSessionId = sessionActiveId;
const activeSessionError = activeSessionId ? (sessionErrors[activeSessionId] ?? null) : null;
const activeSessionErrorCommand = useMemo(
() => extractTerminalCommand(activeSessionError),
[activeSessionError],
);
const {
activeSession,
activeSessionDirectory,
Expand Down Expand Up @@ -178,6 +191,13 @@ function AppContent({

const contextPercent = contextInfo.percent;

const openTerminalForSessionError = useCallback(() => {
if (!activeSessionDirectory) return;
void getDesktopShellClient()
.system.openInTerminal(activeSessionDirectory)
.catch((error) => console.error(error));
}, [activeSessionDirectory]);

// Check for app updates on startup
const updateCheck = useUpdateCheck();

Expand Down Expand Up @@ -256,6 +276,32 @@ function AppContent({
{showPromptBox && (
<div className="shrink-0 px-4 pb-3">
<div className="max-w-2xl mx-auto">
{activeSessionError && (
<Alert variant="destructive" className="mb-2 select-text">
<AlertTitle>{t("sessionError.title")}</AlertTitle>
<AlertDescription className="space-y-2">
<p className="whitespace-pre-wrap break-words">{activeSessionError}</p>
{activeSessionErrorCommand && (
<div className="space-y-1">
<p>{t("sessionError.nextStep")}</p>
<code className="block rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground">
{activeSessionErrorCommand}
</code>
</div>
)}
{activeSessionErrorCommand && activeSessionDirectory && (
<Button
type="button"
variant="outline"
size="sm"
onClick={openTerminalForSessionError}
>
{t("sessionError.openTerminal")}
</Button>
)}
</AlertDescription>
</Alert>
)}
{queuedPrompts.length > 0 && (
<div className="mb-1.5">
<QueueList
Expand Down
11 changes: 8 additions & 3 deletions src/agents/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,14 @@ interface HarnessRuntime {
deleteSession(sessionId: string): Promise<boolean>;
renameSession(sessionId: string, title: string): Promise<Session>;
compactSession(sessionId: string, model?: SelectedModel, target?: HarnessTarget): Promise<void>;
forkSession(sessionId: string, messageID?: string): Promise<Session>;
revertSession(sessionId: string, messageID: string, partID?: string): Promise<Session>;
unrevertSession(sessionId: string): Promise<Session>;
forkSession(sessionId: string, messageID?: string, target?: HarnessTarget): Promise<Session>;
revertSession(
sessionId: string,
messageID: string,
partID?: string,
target?: HarnessTarget,
): Promise<Session>;
unrevertSession(sessionId: string, target?: HarnessTarget): Promise<Session>;
sendCommand(input: {
sessionId: string;
command: string;
Expand Down
17 changes: 17 additions & 0 deletions src/agents/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,23 @@ describe("normalizeTaggedBackendEvent", () => {
});
});

test("prefixes session error ids", () => {
const event = {
type: "pi:event",
payload: {
type: "session.error",
sessionID: "raw-session",
error: "Claude auth expired",
},
} as unknown as NativeBackendEvent;

expect(normalizeTaggedBackendEvent("pi", event, "pi:event")).toEqual({
type: "session.error",
sessionID: "pi:raw-session",
error: "Claude auth expired",
});
});

test("ignores unrelated native event channels", () => {
const event = {
type: "pi:event",
Expand Down
4 changes: 4 additions & 0 deletions src/agents/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ function normalizeBackendEventPayload(harnessId: HarnessId, payload: HarnessEven
case "permission.cleared":
case "question.cleared":
return { ...payload, sessionID: codec.compose(payload.sessionID) };
case "session.error":
return payload.sessionID
? { ...payload, sessionID: codec.compose(payload.sessionID) }
: payload;
case "permission.requested":
return {
...payload,
Expand Down
Loading
Loading