@plannotator/pi-extension 0.17.10 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,13 @@
2
2
  /**
3
3
  * OpenCode provider — bridges Plannotator's AI layer with OpenCode's agent server.
4
4
  *
5
- * Uses @opencode-ai/sdk to spawn `opencode serve` and communicate via HTTP + SSE.
6
- * One server per provider, shared across all sessions. The user must have the
7
- * `opencode` CLI installed and authenticated.
5
+ * Uses @opencode-ai/sdk to connect to an existing `opencode serve` first and
6
+ * only spawns a new server when nothing is reachable. One server is shared
7
+ * across all sessions. The user must have the `opencode` CLI installed and
8
+ * authenticated.
8
9
  */
9
10
 
11
+ import type { OpencodeClient } from "@opencode-ai/sdk";
10
12
  import { BaseSession } from "../base-session.ts";
11
13
  import { buildSystemPrompt } from "../context.ts";
12
14
  import type {
@@ -55,17 +57,17 @@ export class OpenCodeProvider implements AIProvider {
55
57
  private config: OpenCodeConfig;
56
58
  // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
57
59
  private server: { url: string; close: () => void } | null = null;
58
- // biome-ignore lint/suspicious/noExplicitAny: SDK types not available at compile time
59
- private client: any = null;
60
+ private client: OpencodeClient | null = null;
60
61
  private startPromise: Promise<void> | null = null;
62
+ private lastAttachError: string | null = null;
61
63
 
62
64
  constructor(config: OpenCodeConfig) {
63
65
  this.config = config;
64
66
  }
65
67
 
66
- /** Lazy-spawn the OpenCode server and create the HTTP client. */
68
+ /** Attach to an existing OpenCode server or spawn one if needed. */
67
69
  async ensureServer(): Promise<void> {
68
- if (this.server && this.client) return;
70
+ if (this.client) return;
69
71
  this.startPromise ??= this.doStart().catch((err) => {
70
72
  this.startPromise = null;
71
73
  throw err;
@@ -74,13 +76,27 @@ export class OpenCodeProvider implements AIProvider {
74
76
  }
75
77
 
76
78
  private async doStart(): Promise<void> {
79
+ this.lastAttachError = null;
77
80
  const { createOpencodeServer, createOpencodeClient } = await getSDK();
81
+ const attachedClient = await this.tryAttachExistingServer(createOpencodeClient);
82
+ if (attachedClient) {
83
+ this.client = attachedClient;
84
+ return;
85
+ }
78
86
 
79
- this.server = await createOpencodeServer({
80
- hostname: this.config.hostname ?? "127.0.0.1",
81
- ...(this.config.port != null && { port: this.config.port }),
82
- timeout: 15_000,
83
- });
87
+ try {
88
+ this.server = await createOpencodeServer({
89
+ hostname: this.config.hostname ?? "127.0.0.1",
90
+ ...(this.config.port != null && { port: this.config.port }),
91
+ timeout: 15_000,
92
+ });
93
+ } catch (err) {
94
+ const spawnMessage = err instanceof Error ? err.message : String(err);
95
+ if (this.lastAttachError) {
96
+ throw new Error(`${this.lastAttachError}\nFallback startup also failed: ${spawnMessage}`);
97
+ }
98
+ throw err;
99
+ }
84
100
 
85
101
  this.client = createOpencodeClient({
86
102
  baseUrl: this.server!.url,
@@ -88,18 +104,52 @@ export class OpenCodeProvider implements AIProvider {
88
104
  });
89
105
  }
90
106
 
107
+ private async tryAttachExistingServer(
108
+ createOpencodeClient: (config?: { baseUrl?: string; directory?: string }) => OpencodeClient,
109
+ ): Promise<OpencodeClient | null> {
110
+ const cwd = this.config.cwd ?? process.cwd();
111
+ const baseUrl = `http://${this.config.hostname ?? "127.0.0.1"}:${this.config.port ?? 4096}`;
112
+ const client = createOpencodeClient({
113
+ baseUrl,
114
+ directory: cwd,
115
+ });
116
+
117
+ try {
118
+ await client.config.get({
119
+ throwOnError: true,
120
+ signal: AbortSignal.timeout(1_000),
121
+ });
122
+ return client;
123
+ } catch (err) {
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ this.lastAttachError = `Failed to attach to existing OpenCode server at ${baseUrl}: ${message}`;
126
+ return null;
127
+ }
128
+ }
129
+
130
+ private getClient(): OpencodeClient {
131
+ if (!this.client) {
132
+ throw new Error("OpenCode client is not initialized.");
133
+ }
134
+ return this.client;
135
+ }
136
+
91
137
  async createSession(options: CreateSessionOptions): Promise<AISession> {
92
138
  await this.ensureServer();
139
+ const client = this.getClient();
93
140
 
94
- const result = await this.client.session.create({
141
+ const result = await client.session.create({
95
142
  query: { directory: options.cwd ?? this.config.cwd ?? process.cwd() },
96
143
  });
97
144
  const sessionData = result.data;
145
+ if (!sessionData) {
146
+ throw new Error("OpenCode did not return session data.");
147
+ }
98
148
 
99
149
  const session = new OpenCodeSession({
100
150
  sessionId: sessionData.id,
101
151
  systemPrompt: buildSystemPrompt(options.context),
102
- client: this.client,
152
+ client,
103
153
  model: options.model,
104
154
  parentSessionId: null,
105
155
  });
@@ -108,21 +158,25 @@ export class OpenCodeProvider implements AIProvider {
108
158
 
109
159
  async forkSession(options: CreateSessionOptions): Promise<AISession> {
110
160
  await this.ensureServer();
161
+ const client = this.getClient();
111
162
 
112
163
  const parentId = options.context.parent?.sessionId;
113
164
  if (!parentId) {
114
165
  throw new Error("Fork requires a parent session ID.");
115
166
  }
116
167
 
117
- const result = await this.client.session.fork({
168
+ const result = await client.session.fork({
118
169
  path: { id: parentId },
119
170
  });
120
171
  const sessionData = result.data;
172
+ if (!sessionData) {
173
+ throw new Error("OpenCode did not return forked session data.");
174
+ }
121
175
 
122
176
  return new OpenCodeSession({
123
177
  sessionId: sessionData.id,
124
178
  systemPrompt: buildSystemPrompt(options.context),
125
- client: this.client,
179
+ client,
126
180
  model: options.model,
127
181
  parentSessionId: parentId,
128
182
  });
@@ -130,14 +184,15 @@ export class OpenCodeProvider implements AIProvider {
130
184
 
131
185
  async resumeSession(sessionId: string): Promise<AISession> {
132
186
  await this.ensureServer();
187
+ const client = this.getClient();
133
188
 
134
189
  // Verify session exists
135
- await this.client.session.get({ path: { id: sessionId } });
190
+ await client.session.get({ path: { id: sessionId } });
136
191
 
137
192
  return new OpenCodeSession({
138
193
  sessionId,
139
194
  systemPrompt: null,
140
- client: this.client,
195
+ client,
141
196
  model: undefined,
142
197
  parentSessionId: null,
143
198
  });
@@ -147,32 +202,33 @@ export class OpenCodeProvider implements AIProvider {
147
202
  if (this.server) {
148
203
  this.server.close();
149
204
  this.server = null;
150
- this.client = null;
151
- this.startPromise = null;
152
205
  }
206
+ this.client = null;
207
+ this.startPromise = null;
153
208
  }
154
209
 
155
210
  /** Fetch available models from OpenCode. Call before registering the provider. */
156
211
  async fetchModels(): Promise<void> {
157
212
  try {
158
213
  await this.ensureServer();
214
+ const client = this.getClient();
159
215
 
160
- const result = await this.client.provider.list({
216
+ const result = await client.provider.list({
161
217
  query: { directory: this.config.cwd ?? process.cwd() },
162
218
  });
163
219
  const data = result.data;
164
- const connected = new Set(data.connected as string[]);
165
- const allProviders = data.all as Array<{
166
- id: string;
167
- models: Record<string, { id: string; providerID: string; name: string }>;
168
- }>;
220
+ if (!data) {
221
+ return;
222
+ }
223
+ const connected = new Set(data.connected ?? []);
224
+ const allProviders = data.all ?? [];
169
225
 
170
226
  const models: Array<{ id: string; label: string; default?: boolean }> = [];
171
227
  for (const provider of allProviders) {
172
228
  if (!connected.has(provider.id)) continue;
173
229
  for (const model of Object.values(provider.models)) {
174
230
  models.push({
175
- id: `${model.providerID}/${model.id}`,
231
+ id: `${provider.id}/${model.id}`,
176
232
  label: model.name ?? model.id,
177
233
  });
178
234
  }
@@ -365,6 +365,6 @@ export interface OpenCodeConfig extends AIProviderConfig {
365
365
  type: "opencode-sdk";
366
366
  /** Hostname for the OpenCode server. Default: "127.0.0.1". */
367
367
  hostname?: string;
368
- /** Port for the OpenCode server. Default: random. */
368
+ /** Port for the OpenCode server. Default: 4096. */
369
369
  port?: number;
370
370
  }
@@ -10,6 +10,7 @@
10
10
  * Used by both the CLI (`plannotator annotate`) and the `/api/doc` endpoint.
11
11
  */
12
12
 
13
+ import { homedir } from "os";
13
14
  import { isAbsolute, join, resolve, win32 } from "path";
14
15
  import { existsSync, readdirSync, type Dirent } from "fs";
15
16
 
@@ -43,8 +44,93 @@ function stripTrailingSlashes(input: string): string {
43
44
  return input.replace(/\/+$/, "");
44
45
  }
45
46
 
47
+ export function expandHomePath(input: string, home = homedir()): string {
48
+ if (input === "~") {
49
+ return home;
50
+ }
51
+
52
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
53
+ return join(home, input.slice(2));
54
+ }
55
+
56
+ return input;
57
+ }
58
+
59
+ function stripWrappingQuotes(input: string): string {
60
+ if (input.length < 2) {
61
+ return input;
62
+ }
63
+
64
+ const first = input[0];
65
+ const last = input[input.length - 1];
66
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
67
+ return input.slice(1, -1);
68
+ }
69
+
70
+ return input;
71
+ }
72
+
73
+ export function normalizeUserPathInput(
74
+ input: string,
75
+ platform = process.platform,
76
+ ): string {
77
+ const trimmedInput = input.trim();
78
+ const unquotedInput = stripWrappingQuotes(trimmedInput);
79
+ const expandedInput = expandHomePath(unquotedInput);
80
+
81
+ if (platform !== "win32") {
82
+ return expandedInput;
83
+ }
84
+
85
+ for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) {
86
+ const match = expandedInput.match(pattern);
87
+ if (!match) {
88
+ continue;
89
+ }
90
+
91
+ const [, driveLetter, rest] = match;
92
+ return `${driveLetter.toUpperCase()}:/${rest}`;
93
+ }
94
+
95
+ return expandedInput;
96
+ }
97
+
98
+ function isAbsoluteNormalizedUserPath(
99
+ input: string,
100
+ platform = process.platform,
101
+ ): boolean {
102
+ if (hasWindowsDriveLetter(input)) {
103
+ return true;
104
+ }
105
+
106
+ return platform === "win32"
107
+ ? win32.isAbsolute(input)
108
+ : isAbsolute(input);
109
+ }
110
+
111
+ export function isAbsoluteUserPath(
112
+ input: string,
113
+ platform = process.platform,
114
+ ): boolean {
115
+ return isAbsoluteNormalizedUserPath(normalizeUserPathInput(input, platform), platform);
116
+ }
117
+
118
+ export function resolveUserPath(
119
+ input: string,
120
+ baseDir = process.cwd(),
121
+ platform = process.platform,
122
+ ): string {
123
+ const normalizedInput = normalizeUserPathInput(input, platform);
124
+ if (!normalizedInput) {
125
+ return "";
126
+ }
127
+ return isAbsoluteNormalizedUserPath(normalizedInput, platform)
128
+ ? resolveAbsolutePath(normalizedInput, platform)
129
+ : resolve(baseDir, normalizedInput);
130
+ }
131
+
46
132
  function normalizeComparablePath(input: string): string {
47
- return stripTrailingSlashes(normalizeSeparators(resolve(input)));
133
+ return stripTrailingSlashes(normalizeSeparators(resolveUserPath(input)));
48
134
  }
49
135
 
50
136
  export function isWithinProjectRoot(candidate: string, projectRoot: string): boolean {
@@ -79,20 +165,6 @@ function isSearchableMarkdownPath(input: string): boolean {
79
165
  return MARKDOWN_PATH_REGEX.test(input.trim());
80
166
  }
81
167
 
82
- function stripWrappingQuotes(input: string): string {
83
- if (input.length < 2) {
84
- return input;
85
- }
86
-
87
- const first = input[0];
88
- const last = input[input.length - 1];
89
- if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
90
- return input.slice(1, -1);
91
- }
92
-
93
- return input;
94
- }
95
-
96
168
  /** Check if a path looks like a Windows absolute path (e.g. C:\ or C:/) */
97
169
  function hasWindowsDriveLetter(input: string): boolean {
98
170
  return /^[a-zA-Z]:[/\\]/.test(input);
@@ -128,42 +200,6 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], ignored
128
200
  }
129
201
  }
130
202
 
131
- export function normalizeMarkdownPathInput(
132
- input: string,
133
- platform = process.platform,
134
- ): string {
135
- if (platform !== "win32") {
136
- return input;
137
- }
138
-
139
- for (const pattern of WINDOWS_DRIVE_PATH_PATTERNS) {
140
- const match = input.match(pattern);
141
- if (!match) {
142
- continue;
143
- }
144
-
145
- const [, driveLetter, rest] = match;
146
- return `${driveLetter.toUpperCase()}:/${rest}`;
147
- }
148
-
149
- return input;
150
- }
151
-
152
- export function isAbsoluteMarkdownPath(
153
- input: string,
154
- platform = process.platform,
155
- ): boolean {
156
- const normalizedInput = normalizeMarkdownPathInput(input, platform);
157
- // Always check for Windows drive letters (handles compiled Bun exes where
158
- // process.platform may not reflect the actual OS correctly)
159
- if (hasWindowsDriveLetter(normalizedInput)) {
160
- return true;
161
- }
162
- return platform === "win32"
163
- ? win32.isAbsolute(normalizedInput)
164
- : isAbsolute(normalizedInput);
165
- }
166
-
167
203
  /**
168
204
  * Resolve a markdown file path within a project root.
169
205
  *
@@ -174,7 +210,7 @@ function resolveMarkdownFileCore(
174
210
  input: string,
175
211
  projectRoot: string,
176
212
  ): ResolveResult {
177
- const normalizedInput = normalizeMarkdownPathInput(input);
213
+ const normalizedInput = normalizeUserPathInput(input);
178
214
  const searchInput = normalizeSeparators(normalizedInput);
179
215
  const isBareFilename = !searchInput.includes("/");
180
216
  const targetLookupKey = getLookupKey(searchInput, isBareFilename);
@@ -186,7 +222,7 @@ function resolveMarkdownFileCore(
186
222
 
187
223
  // 1. Absolute path — use as-is (no project root restriction;
188
224
  // the user explicitly typed the full path)
189
- if (isAbsoluteMarkdownPath(normalizedInput)) {
225
+ if (isAbsoluteNormalizedUserPath(normalizedInput)) {
190
226
  const absolutePath = resolveAbsolutePath(normalizedInput);
191
227
  if (fileExists(absolutePath)) {
192
228
  return { kind: "found", path: absolutePath };
@@ -12,6 +12,7 @@ import { homedir } from "os";
12
12
  import { join, resolve, sep } from "path";
13
13
  import { mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, existsSync } from "fs";
14
14
  import { sanitizeTag } from "./project";
15
+ import { resolveUserPath } from "./resolve-file";
15
16
 
16
17
  /**
17
18
  * Get the plan storage directory, creating it if needed.
@@ -21,16 +22,12 @@ import { sanitizeTag } from "./project";
21
22
  export function getPlanDir(customPath?: string | null): string {
22
23
  let planDir: string;
23
24
 
24
- if (customPath) {
25
- // Expand ~ to home directory
26
- planDir = customPath.startsWith("~")
27
- ? join(homedir(), customPath.slice(1))
28
- : customPath;
25
+ if (customPath?.trim()) {
26
+ planDir = resolveUserPath(customPath);
29
27
  } else {
30
28
  planDir = join(homedir(), ".plannotator", "plans");
31
29
  }
32
30
 
33
- planDir = resolve(planDir);
34
31
  mkdirSync(planDir, { recursive: true });
35
32
  return planDir;
36
33
  }
@@ -15,7 +15,7 @@ export interface UrlToMarkdownOptions {
15
15
 
16
16
  export interface UrlToMarkdownResult {
17
17
  markdown: string;
18
- source: "jina" | "fetch+turndown" | "fetch-raw";
18
+ source: "jina" | "fetch+turndown" | "fetch-raw" | "content-negotiation";
19
19
  }
20
20
 
21
21
  const FETCH_TIMEOUT_MS = 30_000;
@@ -87,7 +87,17 @@ export async function urlToMarkdown(
87
87
  // Server returned HTML for this .md URL — fall through to normal conversion
88
88
  }
89
89
 
90
- if (options.useJina && !isLocalUrl(url)) {
90
+ // Content negotiation fast path — if the server natively returns markdown
91
+ // (e.g. Cloudflare's Markdown for Agents), skip Jina/Turndown entirely.
92
+ const local = isLocalUrl(url);
93
+ if (!local) {
94
+ const negotiated = await fetchViaContentNegotiation(url);
95
+ if (negotiated !== null) {
96
+ return { markdown: negotiated, source: "content-negotiation" };
97
+ }
98
+ }
99
+
100
+ if (options.useJina && !local) {
91
101
  try {
92
102
  const markdown = await fetchViaJina(url);
93
103
  return { markdown, source: "jina" };
@@ -191,6 +201,57 @@ async function fetchRawText(url: string): Promise<string | null> {
191
201
  }
192
202
  }
193
203
 
204
+ /**
205
+ * Content negotiation fast path — request `text/markdown` via the Accept header.
206
+ * Sites that support Cloudflare's "Markdown for Agents" (or similar) will return
207
+ * markdown directly, letting us skip Jina and Turndown entirely.
208
+ * Returns null if the server doesn't serve markdown.
209
+ */
210
+ const NEGOTIATION_TIMEOUT_MS = 5_000; // Short timeout — this is a best-effort optimization
211
+
212
+ async function fetchViaContentNegotiation(url: string): Promise<string | null> {
213
+ const controller = new AbortController();
214
+ const timer = setTimeout(() => controller.abort(), NEGOTIATION_TIMEOUT_MS);
215
+ const headers = {
216
+ "User-Agent": "Mozilla/5.0 (compatible; Plannotator/1.0; +https://plannotator.ai)",
217
+ Accept: "text/markdown, text/html;q=0.9",
218
+ };
219
+
220
+ try {
221
+ let currentUrl = url;
222
+ let res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
223
+
224
+ for (let i = 0; i < MAX_REDIRECTS && REDIRECT_STATUSES.has(res.status); i++) {
225
+ const location = res.headers.get("location");
226
+ if (!location) break;
227
+ currentUrl = new URL(location, currentUrl).href;
228
+ if (isLocalUrl(currentUrl)) {
229
+ res.body?.cancel();
230
+ return null;
231
+ }
232
+ res.body?.cancel();
233
+ res = await fetch(currentUrl, { headers, redirect: "manual", signal: controller.signal });
234
+ }
235
+
236
+ if (!res.ok) {
237
+ res.body?.cancel();
238
+ return null;
239
+ }
240
+
241
+ const ct = res.headers.get("content-type") || "";
242
+ if (!ct.includes("text/markdown")) {
243
+ res.body?.cancel();
244
+ return null;
245
+ }
246
+
247
+ return await readBodyWithLimit(res);
248
+ } catch {
249
+ return null;
250
+ } finally {
251
+ clearTimeout(timer);
252
+ }
253
+ }
254
+
194
255
  /** Fetch via Jina Reader — returns markdown directly. */
195
256
  async function fetchViaJina(url: string): Promise<string> {
196
257
  // Strip fragment (never sent to server) and encode for Jina's path-based API
package/index.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { existsSync, readFileSync, statSync } from "node:fs";
22
- import { resolve, basename } from "node:path";
22
+ import { basename, resolve } from "node:path";
23
23
  import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
24
24
  import { Type } from "@mariozechner/pi-ai";
25
25
  import type {
@@ -34,7 +34,7 @@ import {
34
34
  parseChecklist,
35
35
  } from "./generated/checklist.js";
36
36
  import { planDenyFeedback } from "./generated/feedback-templates.js";
37
- import { hasMarkdownFiles } from "./generated/resolve-file.js";
37
+ import { hasMarkdownFiles, resolveUserPath } from "./generated/resolve-file.js";
38
38
  import { FILE_BROWSER_EXCLUDED } from "./generated/reference-common.js";
39
39
  import { htmlToMarkdown } from "./generated/html-to-markdown.js";
40
40
  import { urlToMarkdown } from "./generated/url-to-markdown.js";
@@ -417,7 +417,7 @@ export default function plannotator(pi: ExtensionAPI): void {
417
417
  absolutePath = filePath;
418
418
  sourceInfo = filePath;
419
419
  } else {
420
- absolutePath = resolve(ctx.cwd, filePath);
420
+ absolutePath = resolveUserPath(filePath, ctx.cwd);
421
421
  if (!existsSync(absolutePath)) {
422
422
  ctx.ui.notify(`File not found: ${absolutePath}`, "error");
423
423
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.17.10",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "Plannotator extension for Pi coding agent - interactive plan review with visual annotation",
6
6
  "author": "backnotprop",
@@ -44,5 +44,11 @@
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@mariozechner/pi-coding-agent": ">=0.53.0"
47
+ },
48
+ "devDependencies": {
49
+ "@mariozechner/pi-coding-agent": ">=0.53.0",
50
+ "@mariozechner/pi-agent-core": ">=0.53.0",
51
+ "@mariozechner/pi-ai": ">=0.53.0",
52
+ "@mariozechner/pi-tui": ">=0.53.0"
47
53
  }
48
54
  }
@@ -356,6 +356,7 @@ export async function openCodeReview(
356
356
  htmlContent: reviewHtmlContent,
357
357
  sharingEnabled: process.env.PLANNOTATOR_SHARE !== "disabled",
358
358
  shareBaseUrl: process.env.PLANNOTATOR_SHARE_URL || undefined,
359
+ pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined,
359
360
  onCleanup: worktreeCleanup,
360
361
  });
361
362