@plannotator/pi-extension 0.27.10 → 0.27.11

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,10 +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 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.
5
+ * Uses @opencode-ai/sdk to spawn a dedicated `opencode serve` on an
6
+ * OS-assigned port. One server is shared across all sessions of this process,
7
+ * closed on dispose and on process exit. This provider deliberately never
8
+ * attaches to a server it did not spawn: an attached server cannot be cleaned
9
+ * up by us, and opencode's per-directory instances accumulate in it without
10
+ * eviction, so a shared long-lived server grows without bound. The user must
11
+ * have the `opencode` CLI installed and authenticated.
9
12
  */
10
13
 
11
14
  import type { OpencodeClient } from "@opencode-ai/sdk";
@@ -59,13 +62,19 @@ export class OpenCodeProvider implements AIProvider {
59
62
  private server: { url: string; close: () => void } | null = null;
60
63
  private client: OpencodeClient | null = null;
61
64
  private startPromise: Promise<void> | null = null;
62
- private lastAttachError: string | null = null;
65
+ private exitHandler: (() => void) | null = null;
66
+ /**
67
+ * Bumped by dispose() to invalidate an in-flight doStart: a spawn that
68
+ * completes after its epoch has passed reaps its own server instead of
69
+ * resurrecting a provider the runtime already considers disposed.
70
+ */
71
+ private startEpoch = 0;
63
72
 
64
73
  constructor(config: OpenCodeConfig) {
65
74
  this.config = config;
66
75
  }
67
76
 
68
- /** Attach to an existing OpenCode server or spawn one if needed. */
77
+ /** Spawn this process's OpenCode server if it is not already running. */
69
78
  async ensureServer(): Promise<void> {
70
79
  if (this.client) return;
71
80
  this.startPromise ??= this.doStart().catch((err) => {
@@ -76,55 +85,63 @@ export class OpenCodeProvider implements AIProvider {
76
85
  }
77
86
 
78
87
  private async doStart(): Promise<void> {
79
- this.lastAttachError = null;
88
+ const epoch = this.startEpoch;
80
89
  const { createOpencodeServer, createOpencodeClient } = await getSDK();
81
- const attachedClient = await this.tryAttachExistingServer(createOpencodeClient);
82
- if (attachedClient) {
83
- this.client = attachedClient;
84
- return;
85
- }
90
+ // port 0 asks opencode for an OS-assigned free port (the SDK reads the
91
+ // real URL back from the child's "listening" line), so every Plannotator
92
+ // process gets its own server instead of piling onto a shared default
93
+ // port. An explicitly configured port is still honored verbatim.
94
+ const server: { url: string; close: () => void } = await createOpencodeServer({
95
+ hostname: this.config.hostname ?? "127.0.0.1",
96
+ port: this.config.port ?? 0,
97
+ timeout: 15_000,
98
+ });
99
+ // A SIGINT/SIGTERM death is routed through process.exit() by the CLI, so
100
+ // an "exit" handler is what keeps Ctrl-C from orphaning the spawned
101
+ // `opencode serve` child (server.close() kills it synchronously). The
102
+ // closure captures ITS server — never `this.server`, which a failed
103
+ // retry could have replaced, leaving the first child unreachable by any
104
+ // cleanup. No SIGHUP listener: that would override the ignored
105
+ // disposition `nohup` depends on.
106
+ const exitHandler = () => {
107
+ try {
108
+ server.close();
109
+ } catch {
110
+ // Best effort — the process is exiting either way.
111
+ }
112
+ };
113
+ process.once("exit", exitHandler);
86
114
 
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}`);
115
+ const reap = () => {
116
+ process.removeListener("exit", exitHandler);
117
+ try {
118
+ server.close();
119
+ } catch {
120
+ // Best effort — the child may already be gone.
97
121
  }
98
- throw err;
122
+ };
123
+
124
+ if (epoch !== this.startEpoch) {
125
+ // dispose() ran while the spawn was in flight: the runtime no longer
126
+ // wants this provider, so reap the just-spawned server instead of
127
+ // resurrecting a disposed provider with a live child.
128
+ reap();
129
+ throw new Error("OpenCode provider was disposed during startup.");
99
130
  }
100
131
 
101
- this.client = createOpencodeClient({
102
- baseUrl: this.server!.url,
103
- directory: this.config.cwd ?? process.cwd(),
104
- });
105
- }
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
132
  try {
118
- await client.config.get({
119
- throwOnError: true,
120
- signal: AbortSignal.timeout(1_000),
133
+ this.client = createOpencodeClient({
134
+ baseUrl: server.url,
135
+ directory: this.config.cwd ?? process.cwd(),
121
136
  });
122
- return client;
123
137
  } 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;
138
+ // A post-spawn failure must not leak the child: close it and drop the
139
+ // handler so a retry starts from a clean slate.
140
+ reap();
141
+ throw err;
127
142
  }
143
+ this.server = server;
144
+ this.exitHandler = exitHandler;
128
145
  }
129
146
 
130
147
  private getClient(): OpencodeClient {
@@ -199,6 +216,13 @@ export class OpenCodeProvider implements AIProvider {
199
216
  }
200
217
 
201
218
  dispose(): void {
219
+ // Invalidate any in-flight doStart so a spawn completing after this
220
+ // point reaps itself instead of resurrecting the provider.
221
+ this.startEpoch++;
222
+ if (this.exitHandler) {
223
+ process.removeListener("exit", this.exitHandler);
224
+ this.exitHandler = null;
225
+ }
202
226
  if (this.server) {
203
227
  this.server.close();
204
228
  this.server = null;
@@ -316,6 +316,6 @@ export interface OpenCodeConfig extends AIProviderConfig {
316
316
  type: "opencode-sdk";
317
317
  /** Hostname for the OpenCode server. Default: "127.0.0.1". */
318
318
  hostname?: string;
319
- /** Port for the OpenCode server. Default: 4096. */
319
+ /** Port for the spawned OpenCode server. Default: 0 (OS-assigned free port). */
320
320
  port?: number;
321
321
  }
@@ -181,6 +181,16 @@ export interface PlannotatorConfig {
181
181
  * annotate sessions fully stateless. Default: true.
182
182
  */
183
183
  annotateHistory?: boolean;
184
+ /**
185
+ * Durably archive every submitted review under ~/.plannotator/feedback/
186
+ * (or PLANNOTATOR_DATA_DIR): one append-only JSONL record per submission
187
+ * plus a markdown sidecar for the ones that carry content. NOTE: this
188
+ * writes the user's own feedback text and the document/code excerpts it
189
+ * quotes to disk, and nothing prunes the directory. Set to false to never
190
+ * write. Default: true. Annotate-surface records additionally honor
191
+ * `annotateHistory`, so the stateless-annotate promise is unchanged.
192
+ */
193
+ feedbackHistory?: boolean;
184
194
  /**
185
195
  * Extra file extensions annotate treats as markdown (#1307), e.g.
186
196
  * [".livemd"] for Livebook notebooks. Listed extensions are accepted
@@ -648,6 +658,25 @@ export function resolveAnnotateHistory(config: PlannotatorConfig): boolean {
648
658
  return coerceConfigBoolean(config.annotateHistory, true);
649
659
  }
650
660
 
661
+ /**
662
+ * Resolve whether submitted feedback is archived under feedback/.
663
+ *
664
+ * Priority (highest wins):
665
+ * PLANNOTATOR_FEEDBACK_HISTORY env var → config.feedbackHistory → default true
666
+ *
667
+ * Deliberately a separate knob from annotateHistory: that one governs copying
668
+ * ANNOTATED CONTENT into the data dir, this one governs keeping the user's own
669
+ * SUBMISSIONS, and a code-review user must be able to control the second
670
+ * without touching the first. Annotate surfaces honor both.
671
+ */
672
+ export function resolveFeedbackHistory(config: PlannotatorConfig): boolean {
673
+ const envVal = process.env.PLANNOTATOR_FEEDBACK_HISTORY;
674
+ if (envVal !== undefined) {
675
+ return envVal === "1" || envVal.toLowerCase() === "true";
676
+ }
677
+ return coerceConfigBoolean(config.feedbackHistory, true);
678
+ }
679
+
651
680
  /**
652
681
  * Resolve whether successful Guided Reviews are persisted to disk.
653
682
  *