Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ If you want to contribute or develop the plugin:
- Server starts automatically when you open the panel


## How it works (OpenCode 2.x)

OpenCode 2 changed both the shape of its web app and its HTTP API. The plugin now:

- hands the server a password via `OPENCODE_SERVER_PASSWORD` and authenticates
every API call with HTTP Basic (`opencode:<password>`), because 2.x requires
authentication for `/api/*`;
- opens the app at `<server>/server/<serverKey>/session/<id>`, where
`<serverKey>` is the base64url encoding of the server URL - the old
`/<base64-directory>` path is no longer a valid route (it made the app throw
`Unrecognised route!` / `Cannot read properties of undefined (reading 'type')`);
- creates the session for the vault, so file tree, `@`-mentions, terminal and git
are scoped to it;
- embeds the app in an Electron `<webview>` instead of an `<iframe>`: the app
keeps its known servers (url + password) in `localStorage`, which can only be
written from inside the guest, and the pairing-link cookie is `SameSite=Lax`
and therefore never sent from an embedded frame;
- talks to the API through `node:http` rather than `fetch`, because the
renderer's `fetch` is subject to CORS and OpenCode sends no
`access-control-allow-origin` for the `app://obsidian.md` origin.

## Settings

### Custom Command Mode
Expand Down
225 changes: 96 additions & 129 deletions src/client/OpenCodeClient.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,39 @@
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,
resolveSessionId,
} from "../lib/serverUrl";
import { httpRequestJson, type HttpResponse } from "../lib/http";

type OpenCodeSession = {
id?: string;
};

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

/**
* Thin client for the OpenCode 2.x HTTP API (`/api/*`), used for the things the
* plugin needs outside the embedded web UI: resolving the project for the vault
* directory, creating a session to open, and injecting workspace context.
*/
export class OpenCodeClient {
private apiBaseUrl: string;
private uiBaseUrl: string;
private projectDirectory: string;
private password = "";
private trackedSessionId: string | null = null;
private lastPart: OpenCodePart | null = null;
private lastContextText: string | null = null;

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

updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string): void {
const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl);
const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl);
const nextApiUrl = normalizeServerUrl(apiBaseUrl);
const nextUiUrl = normalizeServerUrl(uiBaseUrl);
if (
nextApiUrl !== this.apiBaseUrl ||
nextUiUrl !== this.uiBaseUrl ||
Expand All @@ -57,162 +46,144 @@ export class OpenCodeClient {
}
}

setPassword(password: string): void {
if (password !== this.password) {
this.password = password;
this.resetTracking();
}
}

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

getApiBaseUrl(): string {
return this.apiBaseUrl;
}

/** Make sure the server knows the vault directory as a project. */
async initializeProject(): Promise<boolean> {
try {
const url = `${this.apiBaseUrl}/session?directory=${encodeURIComponent(this.projectDirectory)}`;
const response = await fetch(url, {
method: "GET",
headers: {
"x-opencode-directory": this.projectDirectory,
},
});
const response = await this.request(
"GET",
`/api/location?location[directory]=${encodeURIComponent(this.projectDirectory)}`
);

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.uiBaseUrl, sessionId);
}

resolveSessionId(iframeUrl: string): string | null {
const match = iframeUrl.match(/\/session\/([^/?#]+)/);
return match?.[1] ?? null;
resolveSessionId(embedUrl: string): string | null {
return resolveSessionId(embedUrl);
}

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

/**
* Attach the current workspace context to the session.
*
* v1 stored this as a hidden message part and patched it in place. v2 has no
* part update endpoint; synthetic input is durable instead, so a new message is
* only added when the context actually changed.
*/
async updateContext(params: {
sessionId: string;
contextText: string | null;
}): Promise<void> {
const { sessionId, contextText } = params;

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

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

if (this.lastPart) {
const updated = await this.updatePart(this.lastPart, { text: contextText });
if (updated) {
return;
}
await this.ignorePreviousPart();
}
const response = await this.request("POST", `/api/session/${sessionId}/synthetic`, {
text: contextText,
resume: false,
});

const message = await this.sendPrompt(sessionId, contextText);
if (message?.info?.id) {
this.lastPart = message.parts?.[0] ?? null;
if (!response.ok) {
console.warn("[OpenCode] Context injection failed:", response.status);
return;
}
}

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

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

const message = this.unwrap(result);
if (!message) {
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;
}

const ignored = await this.updatePart(this.lastPart, { ignored: true });
if (!ignored) {
return false;
}

this.lastPart = null;
return true;
private async request(
method: string,
path: string,
body?: unknown
): Promise<HttpResponse> {
// node:http instead of fetch: the renderer's fetch is CORS-blocked against
// the OpenCode server (its preflight carries no allow-origin header).
return httpRequestJson(method, `${this.apiBaseUrl}${path}`, {
headers: this.headers(),
body: body ? JSON.stringify(body) : undefined,
timeoutMs: 15000,
});
}

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

if (!response.ok) {
console.error("[OpenCode] API request failed", {
path,
status: response.status,
});
console.error("[OpenCode] API request failed", { path, status: response.status });
return null;
}

const json = await response
.json()
.catch(() => null);
return json as OpenCodeResponse<T>;
const text = response.body;
if (!text) {
return null;
}
return JSON.parse(text) as OpenCodeResponse<T>;
} catch (error) {
console.error("[OpenCode] API request error", error);
return null;
}
}

private headers(): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.password) {
headers.Authorization = buildAuthHeader(this.password);
}
return headers;
}

private unwrap<T>(result: OpenCodeResponse<T>): T | null {
if (!result) {
return null;
Expand All @@ -228,8 +199,4 @@ export class OpenCodeClient {
}
return result as T;
}

private normalizeBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
}
Loading