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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@ _Note: plugin author is not afiliated with OpenCode or Obsidian - this is a 3rd
## Requirements

- Desktop only (uses Node.js child processes)
- [OpenCode CLI](https://opencode.ai) installed
- [OpenCode CLI](https://opencode.ai) v2 or later installed
- [Bun](https://bun.sh) installed

## Server password

OpenCode v2 requires every client to authenticate. The plugin generates a password on first run, passes it to the server as `OPENCODE_SERVER_PASSWORD`, and reuses it for the embedded web view. You can view or rotate the password in Settings under **Server password**.

If you start the server outside the plugin, set the same password before you start it:

```bash
OPENCODE_SERVER_PASSWORD=<password> opencode serve --port 14096 --hostname 127.0.0.1
```

## Installation

### For Users (BRAT - Recommended for Beta Testing)
Expand Down
179 changes: 57 additions & 122 deletions src/client/OpenCodeClient.ts
Original file line number Diff line number Diff line change
@@ -1,92 +1,70 @@
type OpenCodePart = {
id: string;
messageID: string;
sessionID: string;
type: string;
text?: string;
ignored?: boolean;
synthetic?: boolean;
metadata?: Record<string, unknown>;
time?: {
start: number;
end?: number;
};
};

type OpenCodeMessageInfo = {
id: string;
sessionID: string;
};

type OpenCodeMessageWithParts = {
info: OpenCodeMessageInfo;
parts: OpenCodePart[];
};
import { buildAuthHeader, buildSessionUrl, normalizeServerUrl } from "../lib/serverUrl";

type OpenCodeSession = {
id?: string;
};

type OpenCodeResponse<T> = T | { data?: T } | { message?: T } | null;

const API_PREFIX = "/api";

export class OpenCodeClient {
private apiBaseUrl: string;
private uiBaseUrl: string;
private serverUrl: string;
private password: string;
private projectDirectory: string;
private trackedSessionId: string | null = null;
private lastPart: OpenCodePart | null = null;
private lastInjectedContext: string | null = null;

constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string) {
this.apiBaseUrl = this.normalizeBaseUrl(apiBaseUrl);
this.uiBaseUrl = this.normalizeBaseUrl(uiBaseUrl);
constructor(serverUrl: string, password: string, projectDirectory: string) {
this.serverUrl = normalizeServerUrl(serverUrl);
this.password = password;
this.projectDirectory = projectDirectory;
}

updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string): void {
const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl);
const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl);
updateBaseUrl(serverUrl: string, password: string, projectDirectory: string): void {
const nextServerUrl = normalizeServerUrl(serverUrl);
if (
nextApiUrl !== this.apiBaseUrl ||
nextUiUrl !== this.uiBaseUrl ||
nextServerUrl !== this.serverUrl ||
password !== this.password ||
projectDirectory !== this.projectDirectory
) {
this.apiBaseUrl = nextApiUrl;
this.uiBaseUrl = nextUiUrl;
this.serverUrl = nextServerUrl;
this.password = password;
this.projectDirectory = projectDirectory;
this.resetTracking();
}
}

resetTracking(): void {
this.trackedSessionId = null;
this.lastPart = null;
private resetTracking(): void {
this.lastInjectedContext = null;
}

/**
* Touch the session list for the project directory. v2 creates the project
* lazily on first access, so a list request is enough to register it.
*/
async initializeProject(): Promise<boolean> {
try {
const url = `${this.apiBaseUrl}/session?directory=${encodeURIComponent(this.projectDirectory)}`;
const response = await fetch(url, {
const path = `${API_PREFIX}/session?directory=${encodeURIComponent(this.projectDirectory)}`;
const response = await fetch(`${this.serverUrl}${path}`, {
method: "GET",
headers: {
"x-opencode-directory": this.projectDirectory,
},
headers: this.buildHeaders(),
});

if (response.ok) {
console.log("[OpenCode] Project initialized:", this.projectDirectory);
return true;
} else {
console.warn("[OpenCode] Project initialization failed:", response.status);
return false;
}

console.warn("[OpenCode] Project initialization failed:", response.status);
return false;
} catch (error) {
console.error("[OpenCode] Project initialization error:", error);
return false;
}
}

getSessionUrl(sessionId: string): string {
return `${this.uiBaseUrl}/session/${sessionId}`;
return buildSessionUrl(this.serverUrl, sessionId);
}

resolveSessionId(iframeUrl: string): string | null {
Expand All @@ -95,104 +73,65 @@ export class OpenCodeClient {
}

async createSession(): Promise<string | null> {
const result = await this.request<OpenCodeSession>("POST", "/session", {
const result = await this.request<OpenCodeSession>("POST", `${API_PREFIX}/session`, {
title: "Obsidian",
location: { directory: this.projectDirectory },
});
const session = this.unwrap(result);
return session?.id ?? null;
return this.unwrap(result)?.id ?? null;
}

/**
* Push the current workspace context into the session.
*
* v2 has no endpoint to update or retract an earlier message, so the context
* is sent as a synthetic message. The same text is never sent twice in a row,
* which keeps editor events from flooding the session.
*/
async updateContext(params: {
sessionId: string;
contextText: string | null;
}): Promise<void> {
const { sessionId, contextText } = params;

if (this.trackedSessionId && this.trackedSessionId !== sessionId) {
this.resetTracking();
}
this.trackedSessionId = sessionId;

if (!contextText) {
await this.ignorePreviousPart();
return;
}

if (this.lastPart) {
const updated = await this.updatePart(this.lastPart, { text: contextText });
if (updated) {
return;
}
await this.ignorePreviousPart();
if (this.lastInjectedContext === contextText) {
return;
}

const message = await this.sendPrompt(sessionId, contextText);
if (message?.info?.id) {
this.lastPart = message.parts?.[0] ?? null;
}
}

private async sendPrompt(sessionId: string, contextText: string): Promise<OpenCodeMessageWithParts | null> {
const result = await this.request<OpenCodeMessageWithParts>(
const result = await this.request(
"POST",
`/session/${sessionId}/message`,
{
noReply: true,
parts: [{ type: "text", text: contextText }],
}
`${API_PREFIX}/session/${sessionId}/synthetic`,
{ text: contextText }
);

console.log("[OpenCode] Injected context message");
console.log(contextText)

const message = this.unwrap(result);
if (!message) {
if (result) {
this.lastInjectedContext = contextText;
console.log("[OpenCode] Injected context message");
} else {
console.error("[OpenCode] Failed to inject context message");
}
return message;
}

private async updatePart(part: OpenCodePart, updates: { text?: string; ignored?: boolean }): Promise<boolean> {
const result = await this.request<OpenCodePart>(
"PATCH",
`/session/${part.sessionID}/message/${part.messageID}/part/${part.id}`,
{
...part,
...updates,
}
);
const updated = this.unwrap(result);
if (updated) {
this.lastPart = updated;
return true;
}
return false;
}

private async ignorePreviousPart(): Promise<boolean> {
if (!this.lastPart) {
return false;
private buildHeaders(hasBody = false): Record<string, string> {
const headers: Record<string, string> = {};
if (hasBody) {
headers["Content-Type"] = "application/json";
}

const ignored = await this.updatePart(this.lastPart, { ignored: true });
if (!ignored) {
return false;
if (this.password) {
headers["Authorization"] = buildAuthHeader(this.password);
}

this.lastPart = null;
return true;
return headers;
}

private async request<T>(method: string, path: string, body?: unknown): Promise<OpenCodeResponse<T>> {
try {
const url = `${this.apiBaseUrl}${path}`;
const response = await fetch(url, {
const response = await fetch(`${this.serverUrl}${path}`, {
method,
headers: {
"Content-Type": "application/json",
"x-opencode-directory": this.projectDirectory,
},
body: body ? JSON.stringify(body) : undefined,
headers: this.buildHeaders(body !== undefined),
body: body !== undefined ? JSON.stringify(body) : undefined,
});

if (!response.ok) {
Expand Down Expand Up @@ -228,8 +167,4 @@ export class OpenCodeClient {
}
return result as T;
}

private normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
}
38 changes: 38 additions & 0 deletions src/lib/serverUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* URL and auth helpers for the OpenCode v2 server.
*
* v2 serves one web UI per server. A session lives at:
* <serverUrl>/server/<serverKey>/session/<sessionId>
* where <serverKey> is the base64url encoding of the server URL.
*/

const AUTH_USERNAME = "opencode";

export const STORAGE_KEY_SERVER = "opencode.global.dat:server";

/** Strip trailing slashes so a server URL can be joined safely. */
export function normalizeServerUrl(serverUrl: string): string {
return serverUrl.replace(/\/+$/, "");
}

/** Encode a server URL the way the OpenCode web UI expects in a session route. */
export function encodeServerKey(serverUrl: string): string {
return Buffer.from(normalizeServerUrl(serverUrl), "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}

export function buildSessionUrl(serverUrl: string, sessionId: string): string {
return `${normalizeServerUrl(serverUrl)}/server/${encodeServerKey(serverUrl)}/session/${sessionId}`;
}

/**
* v2 authenticates with HTTP Basic. The username is fixed and the password is
* the one the server prints on startup.
*/
export function buildAuthHeader(password: string): string {
const token = Buffer.from(`${AUTH_USERNAME}:${password}`, "utf8").toString("base64");
return `Basic ${token}`;
}
Loading