@cortexkit/aft-opencode 0.30.1 → 0.30.2

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.
@@ -23,11 +23,12 @@
23
23
  * reachable. OpenCode Desktop (Electron+Node) and TUI launched with
24
24
  * `opencode --port 0` bind a real API listener; plain TUI binds an
25
25
  * internal-only listener that 404s for `/session/*`. We probe once at
26
- * plugin init and cache the result. When the listener is unreachable
27
- * the wake path silently uses the in-process `input.client.session.promptAsync`,
28
- * which keeps wakes flowing (at the cost of the upstream duplicate-runner
29
- * bug) instead of producing no notification at all or nagging the user
30
- * to relaunch with a different flag.
26
+ * plugin init and cache the result by `serverUrl`. When that server is
27
+ * unreachable, the wake path silently uses the in-process
28
+ * `input.client.session.promptAsync`, which keeps wakes flowing (at the
29
+ * cost of the upstream duplicate-runner bug) instead of producing no
30
+ * notification at all or nagging the user to relaunch with a different
31
+ * flag.
31
32
  *
32
33
  * Tracked upstream as anomalyco/opencode#28202. When OpenCode fixes the
33
34
  * runtime split, this helper and its single consumer in `bg-notifications.ts`
@@ -51,6 +52,14 @@ function cacheKey(serverUrl: string, directory: string): string {
51
52
  return `${serverUrl}|${directory}`;
52
53
  }
53
54
 
55
+ function normalizeServerUrl(serverUrl: string): string {
56
+ try {
57
+ return new URL(serverUrl).toString();
58
+ } catch {
59
+ return serverUrl;
60
+ }
61
+ }
62
+
54
63
  /**
55
64
  * Build the Basic-auth header OpenCode's server expects when
56
65
  * `OPENCODE_SERVER_PASSWORD` is set. Read at call time (not at module load)
@@ -95,80 +104,103 @@ export function __resetLiveServerClientCacheForTests(): void {
95
104
  }
96
105
 
97
106
  /**
98
- * Probe whether `serverUrl` accepts a connection within `timeoutMs`.
99
- * Returns `true` for any HTTP response (including 4xx / 5xx) since the
100
- * goal is to confirm the listener exists. Returns `false` on connection
101
- * refused, DNS failure, timeout, or undefined URL.
107
+ * Per-server decision: should bg-notifications use the live-server wake
108
+ * transport (workaround for anomalyco/opencode#28202), or fall back to the
109
+ * in-process `input.client.session.promptAsync` path?
102
110
  *
103
- * Used at plugin init to decide whether bg-notifications should use the
104
- * live-server wake transport (workaround for anomalyco/opencode#28202)
105
- * or fall back to the in-process `input.client.session.promptAsync`
106
- * path. Plain TUI (no `--port 0`) binds an internal-only listener that
107
- * 404s for `/session/...`, so this returns false there; OpenCode
108
- * Desktop, `opencode run`, and `opencode --port 0` TUI return true.
111
+ * Keying by `serverUrl` matters because one plugin process can host
112
+ * multiple OpenCode windows with different live listener URLs. A missing
113
+ * keyed decision defaults to `false` for safety: the in-process client is
114
+ * part of the plugin contract, while the live-server path requires a
115
+ * probe-confirmed listener.
116
+ *
117
+ * `legacyLiveServerWakeAvailable` is retained only for older callers/tests
118
+ * that do not pass a `serverUrl`; keyed wake callers never read it.
119
+ */
120
+ const liveServerWakeAvailableByServerUrl = new Map<string, boolean>();
121
+ let legacyLiveServerWakeAvailable = false;
122
+
123
+ /**
124
+ * Probe whether `serverUrl` serves OpenCode's HTTP API within `timeoutMs`.
125
+ * Returns `true` only when `/session` proves the API is usable: any 2xx
126
+ * response is reachable, and 401/403 also count as reachable because an
127
+ * auth-protected listener still exists. Returns `false` for 404 (plain
128
+ * TUI's internal listener), 5xx, connection refused, DNS failure, timeout,
129
+ * malformed URL, or undefined URL.
130
+ *
131
+ * The probe records its result in the per-`serverUrl` wake-availability
132
+ * cache. That keeps multiple OpenCode windows with different live listener
133
+ * URLs from sharing one process-global transport decision.
109
134
  */
110
135
  export async function probeServerReachable(
111
136
  serverUrl: string | undefined,
112
137
  timeoutMs = 1500,
113
138
  ): Promise<boolean> {
114
139
  if (!serverUrl) return false;
140
+ const normalizedServerUrl = normalizeServerUrl(serverUrl);
115
141
  const controller = new AbortController();
116
142
  const timer = setTimeout(() => controller.abort(), timeoutMs);
143
+ let reachable = false;
117
144
  try {
118
145
  // Hit a path that actually exists on the OpenCode HTTP API so a
119
- // 200 confirms the API server is up, not just any random listener
120
- // (e.g. an internal IPC port that happens to accept TCP but rejects
121
- // all paths with 404 — which is exactly what TUI binds without
146
+ // successful response confirms the API server is up, not just any
147
+ // random listener (e.g. an internal IPC port that accepts TCP but
148
+ // rejects all paths with 404 — exactly what TUI binds without
122
149
  // `--port 0`).
123
150
  const probeUrl = new URL("/session", serverUrl).toString();
124
151
  const res = await globalThis.fetch(probeUrl, {
125
152
  method: "GET",
153
+ headers: serverAuthHeaders(),
126
154
  signal: controller.signal,
127
155
  });
128
- return res.status >= 200 && res.status < 500;
156
+ reachable = res.ok || res.status === 401 || res.status === 403;
129
157
  } catch {
130
- return false;
158
+ reachable = false;
131
159
  } finally {
132
160
  clearTimeout(timer);
161
+ liveServerWakeAvailableByServerUrl.set(normalizedServerUrl, reachable);
133
162
  }
163
+ return reachable;
134
164
  }
135
165
 
136
166
  /**
137
- * Per-plugin-process decision: should bg-notifications use the live-server
138
- * wake transport (workaround for anomalyco/opencode#28202), or fall back
139
- * to the in-process `input.client.session.promptAsync` path?
140
- *
141
- * Set once at plugin init from the result of `probeServerReachable()`.
142
- * Defaults to `false` if no decision has been recorded yet — that's the
143
- * safe direction because `input.client.session.promptAsync` is always
144
- * available (it's part of the plugin contract), whereas the live-server
145
- * path needs both a probe-confirmed listener and the workaround code
146
- * itself to be live.
147
- *
148
- * Read at wake time (not cached on a closure) so background probes that
149
- * complete after plugin init still take effect on the next wake.
150
- */
151
- let liveServerWakeAvailable = false;
152
-
153
- /**
154
- * Record the probe result. Idempotent; if you record twice, the latest
155
- * value wins. The wake path reads through `useLiveServerWake()`.
167
+ * Record a probe result. Prefer the `(serverUrl, available)` form; the
168
+ * single-boolean form is a compatibility fallback for old call sites and
169
+ * tests that do not yet have a URL to key by.
156
170
  */
157
- export function setLiveServerWakeAvailable(available: boolean): void {
158
- liveServerWakeAvailable = available;
171
+ export function setLiveServerWakeAvailable(available: boolean): void;
172
+ export function setLiveServerWakeAvailable(serverUrl: string | undefined, available: boolean): void;
173
+ export function setLiveServerWakeAvailable(
174
+ serverUrlOrAvailable: string | boolean | undefined,
175
+ available?: boolean,
176
+ ): void {
177
+ if (typeof serverUrlOrAvailable === "boolean") {
178
+ legacyLiveServerWakeAvailable = serverUrlOrAvailable;
179
+ return;
180
+ }
181
+ if (!serverUrlOrAvailable) {
182
+ legacyLiveServerWakeAvailable = available ?? false;
183
+ return;
184
+ }
185
+ liveServerWakeAvailableByServerUrl.set(
186
+ normalizeServerUrl(serverUrlOrAvailable),
187
+ available ?? false,
188
+ );
159
189
  }
160
190
 
161
191
  /**
162
- * Read the cached probe decision. `true` means the wake path should use
163
- * `getLiveServerClient(serverUrl, directory)` and POST through the live
164
- * HTTP listener. `false` means fall back to the in-process client passed
165
- * via plugin context (`input.client`).
192
+ * Read the cached probe decision for `serverUrl`. `true` means the wake path
193
+ * should use `getLiveServerClient(serverUrl, directory)` and POST through
194
+ * the live HTTP listener. `false` means fall back to the in-process client
195
+ * passed via plugin context (`input.client`).
166
196
  */
167
- export function useLiveServerWake(): boolean {
168
- return liveServerWakeAvailable;
197
+ export function useLiveServerWake(serverUrl?: string): boolean {
198
+ if (!serverUrl) return legacyLiveServerWakeAvailable;
199
+ return liveServerWakeAvailableByServerUrl.get(normalizeServerUrl(serverUrl)) ?? false;
169
200
  }
170
201
 
171
202
  /** Test helper — reset the decision cache between cases. */
172
203
  export function __resetLiveServerWakeForTests(): void {
173
- liveServerWakeAvailable = false;
204
+ liveServerWakeAvailableByServerUrl.clear();
205
+ legacyLiveServerWakeAvailable = false;
174
206
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
1
+ import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { warn } from "../logger";
4
4
  import { rpcPortFileDir, rpcPortFilePath } from "./rpc-utils";
@@ -7,13 +7,46 @@ const MAX_RETRIES = 10;
7
7
  const RETRY_DELAY_MS = 500;
8
8
  const REQUEST_TIMEOUT_MS = 5000;
9
9
 
10
- type PortInfo = { port: number; token: string | null };
10
+ type PortInfoSource = "instance" | "legacy";
11
+ type ParsedPortInfo = { port: number; token: string | null };
12
+ type PortInfo = ParsedPortInfo & { source: PortInfoSource; path?: string };
13
+
14
+ export interface AftRpcCallOptions {
15
+ signal?: AbortSignal;
16
+ }
17
+
18
+ function abortError(signal: AbortSignal): Error {
19
+ const reason = signal.reason;
20
+ if (reason instanceof Error) return reason;
21
+ return new Error("AFT RPC request aborted");
22
+ }
23
+
24
+ function throwIfAborted(signal?: AbortSignal): void {
25
+ if (signal?.aborted) throw abortError(signal);
26
+ }
27
+
28
+ function delay(ms: number, signal?: AbortSignal): Promise<void> {
29
+ throwIfAborted(signal);
30
+ return new Promise((resolve, reject) => {
31
+ let timer: ReturnType<typeof setTimeout>;
32
+ const onAbort = () => {
33
+ clearTimeout(timer);
34
+ reject(signal ? abortError(signal) : new Error("AFT RPC request aborted"));
35
+ };
36
+ timer = setTimeout(() => {
37
+ signal?.removeEventListener("abort", onAbort);
38
+ resolve();
39
+ }, ms);
40
+ signal?.addEventListener("abort", onAbort, { once: true });
41
+ });
42
+ }
11
43
 
12
44
  export class AftRpcClient {
13
45
  private port: number | null = null;
14
46
  private token: string | null = null;
15
47
  private portsDir: string;
16
48
  private legacyPortFile: string;
49
+ private stalePortFailures = new Map<string, number>();
17
50
 
18
51
  constructor(storageDir: string, directory: string) {
19
52
  this.portsDir = rpcPortFileDir(storageDir, directory);
@@ -24,11 +57,15 @@ export class AftRpcClient {
24
57
  async call<T = Record<string, unknown>>(
25
58
  method: string,
26
59
  params: Record<string, unknown> = {},
60
+ options: AftRpcCallOptions = {},
27
61
  ): Promise<T> {
62
+ const { signal } = options;
63
+ throwIfAborted(signal);
64
+
28
65
  // Try ALL discovered ports for this project (OpenCode TUI under --port 0
29
66
  // loads our plugin twice in the same process, so two RPC servers listen
30
67
  // and we have to try both — only one's bridge is actually warm).
31
- const infos = await this.resolvePortInfos();
68
+ const infos = await this.resolvePortInfos(signal);
32
69
  if (infos.length === 0) {
33
70
  throw new Error("AFT RPC server not available");
34
71
  }
@@ -39,8 +76,9 @@ export class AftRpcClient {
39
76
  let placeholder: T | null = null;
40
77
  let lastError: unknown = null;
41
78
  for (const info of infos) {
79
+ throwIfAborted(signal);
42
80
  try {
43
- const result = await this.callOne<T>(method, params, info);
81
+ const result = await this.callOne<T>(method, params, info, signal);
44
82
  if (this.looksLikePlaceholder(result)) {
45
83
  placeholder = result; // remember but keep trying
46
84
  continue;
@@ -50,6 +88,7 @@ export class AftRpcClient {
50
88
  this.token = info.token;
51
89
  return result;
52
90
  } catch (err) {
91
+ throwIfAborted(signal);
53
92
  lastError = err;
54
93
  }
55
94
  }
@@ -64,12 +103,17 @@ export class AftRpcClient {
64
103
  method: string,
65
104
  params: Record<string, unknown>,
66
105
  info: PortInfo,
106
+ signal?: AbortSignal,
67
107
  ): Promise<T> {
68
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${info.port}/rpc/${method}`, {
69
- method: "POST",
70
- headers: { "Content-Type": "application/json" },
71
- body: JSON.stringify({ ...params, token: info.token }),
72
- });
108
+ const response = await this.fetchWithTimeout(
109
+ `http://127.0.0.1:${info.port}/rpc/${method}`,
110
+ {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ ...params, token: info.token }),
114
+ },
115
+ signal,
116
+ );
73
117
  if (!response.ok) {
74
118
  const text = await response.text();
75
119
  throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
@@ -100,23 +144,28 @@ export class AftRpcClient {
100
144
 
101
145
  /**
102
146
  * Discover all live RPC port files for this project. Tries the per-instance
103
- * directory first (v0.28.2+), then falls back to the single legacy `port`
104
- * file (older plugin versions in mixed deployments).
147
+ * directory first (v0.28.2+), then the single legacy `port` file (older
148
+ * plugin versions in mixed deployments) as the final fallback candidate.
105
149
  */
106
- private async resolvePortInfos(): Promise<PortInfo[]> {
150
+ private async resolvePortInfos(signal?: AbortSignal): Promise<PortInfo[]> {
107
151
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
152
+ throwIfAborted(signal);
108
153
  const infos = this.readAllPortFiles();
109
154
  if (infos.length > 0) {
110
155
  const alive: PortInfo[] = [];
111
156
  for (const info of infos) {
112
- if (await this.healthCheck(info.port)) {
157
+ throwIfAborted(signal);
158
+ if (await this.healthCheck(info.port, signal)) {
159
+ this.clearPortFailure(info);
113
160
  alive.push(info);
161
+ } else {
162
+ this.recordPortFailure(info);
114
163
  }
115
164
  }
116
165
  if (alive.length > 0) return alive;
117
166
  }
118
167
  if (attempt < MAX_RETRIES - 1) {
119
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
168
+ await delay(RETRY_DELAY_MS, signal);
120
169
  }
121
170
  }
122
171
  return [];
@@ -124,30 +173,40 @@ export class AftRpcClient {
124
173
 
125
174
  private readAllPortFiles(): PortInfo[] {
126
175
  const collected: PortInfo[] = [];
176
+ const seenPorts = new Set<number>();
177
+ const add = (info: PortInfo) => {
178
+ if (seenPorts.has(info.port)) return;
179
+ seenPorts.add(info.port);
180
+ collected.push(info);
181
+ };
182
+
127
183
  // Per-instance directory (v0.28.2+): one file per plugin load.
128
184
  if (existsSync(this.portsDir)) {
129
185
  try {
130
- const entries = readdirSync(this.portsDir);
186
+ const entries = readdirSync(this.portsDir).sort();
131
187
  for (const entry of entries) {
132
188
  if (!entry.endsWith(".json")) continue;
133
- const info = this.parsePortFile(join(this.portsDir, entry));
134
- if (info) collected.push(info);
189
+ const filePath = join(this.portsDir, entry);
190
+ const info = this.parsePortFile(filePath);
191
+ if (info) add({ ...info, source: "instance", path: filePath });
135
192
  }
136
193
  } catch {
137
194
  // ignore read errors
138
195
  }
139
196
  }
197
+
140
198
  // Legacy single file (pre-v0.28.2 plugin versions in mixed deployments).
141
- if (collected.length === 0) {
142
- const info = this.parsePortFile(this.legacyPortFile);
143
- if (info) collected.push(info);
144
- }
199
+ // Always append it after per-instance entries so a stale JSON file cannot
200
+ // mask an older live server, then de-dupe by port with per-instance winning.
201
+ const legacyInfo = this.parsePortFile(this.legacyPortFile);
202
+ if (legacyInfo) add({ ...legacyInfo, source: "legacy", path: this.legacyPortFile });
203
+
145
204
  return collected;
146
205
  }
147
206
 
148
- private parsePortFile(path: string): PortInfo | null {
207
+ private parsePortFile(filePath: string): ParsedPortInfo | null {
149
208
  try {
150
- const content = readFileSync(path, "utf-8").trim();
209
+ const content = readFileSync(filePath, "utf-8").trim();
151
210
  let port: number;
152
211
  let token: string | null;
153
212
  if (content.startsWith("{")) {
@@ -168,29 +227,74 @@ export class AftRpcClient {
168
227
  }
169
228
  }
170
229
 
171
- private async healthCheck(port: number): Promise<boolean> {
230
+ private portFailureKey(info: PortInfo): string | null {
231
+ if (info.source !== "instance" || !info.path) return null;
232
+ return `${info.path}\0${info.port}\0${info.token ?? ""}`;
233
+ }
234
+
235
+ private clearPortFailure(info: PortInfo): void {
236
+ const key = this.portFailureKey(info);
237
+ if (key) this.stalePortFailures.delete(key);
238
+ }
239
+
240
+ private recordPortFailure(info: PortInfo): void {
241
+ const key = this.portFailureKey(info);
242
+ if (!key || !info.path) return;
243
+
244
+ const failures = (this.stalePortFailures.get(key) ?? 0) + 1;
245
+ if (failures < 2) {
246
+ this.stalePortFailures.set(key, failures);
247
+ return;
248
+ }
249
+
250
+ this.stalePortFailures.delete(key);
172
251
  try {
173
- const response = await this.fetchWithTimeout(`http://127.0.0.1:${port}/health`, {
174
- method: "GET",
175
- });
252
+ // Do not unlink a replacement written after the failed health checks.
253
+ const current = this.parsePortFile(info.path);
254
+ if (current?.port === info.port && current.token === info.token) {
255
+ unlinkSync(info.path);
256
+ }
257
+ } catch {
258
+ // best-effort stale cleanup only
259
+ }
260
+ }
261
+
262
+ private async healthCheck(port: number, signal?: AbortSignal): Promise<boolean> {
263
+ try {
264
+ const response = await this.fetchWithTimeout(
265
+ `http://127.0.0.1:${port}/health`,
266
+ { method: "GET" },
267
+ signal,
268
+ );
176
269
  return response.ok;
177
270
  } catch {
271
+ throwIfAborted(signal);
178
272
  return false;
179
273
  }
180
274
  }
181
275
 
182
- private async fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
276
+ private async fetchWithTimeout(
277
+ url: string,
278
+ options: RequestInit,
279
+ signal?: AbortSignal,
280
+ ): Promise<Response> {
281
+ throwIfAborted(signal);
282
+
183
283
  const controller = new AbortController();
184
284
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
285
+ const onAbort = () => controller.abort();
286
+ signal?.addEventListener("abort", onAbort, { once: true });
185
287
  try {
186
288
  return await fetch(url, { ...options, signal: controller.signal });
187
289
  } finally {
188
290
  clearTimeout(timeout);
291
+ signal?.removeEventListener("abort", onAbort);
189
292
  }
190
293
  }
191
294
 
192
295
  reset(): void {
193
296
  this.port = null;
194
297
  this.token = null;
298
+ this.stalePortFailures.clear();
195
299
  }
196
300
  }
@@ -0,0 +1,22 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ function homeDir(): string {
5
+ if (process.platform === "win32") return process.env.USERPROFILE || process.env.HOME || homedir();
6
+ return process.env.HOME || homedir();
7
+ }
8
+
9
+ function dataHome(): string {
10
+ // Keep this in sync with the bridge package's storage migration helper,
11
+ // but do not import that package from TUI code: its public barrel also
12
+ // exports URL-fetch helpers unsuitable for Bun's TUI runtime.
13
+ if (process.env.XDG_DATA_HOME) return process.env.XDG_DATA_HOME;
14
+ if (process.platform === "win32") {
15
+ return process.env.LOCALAPPDATA || process.env.APPDATA || join(homeDir(), "AppData", "Local");
16
+ }
17
+ return join(homeDir(), ".local", "share");
18
+ }
19
+
20
+ export function resolveCortexKitStorageRoot(): string {
21
+ return join(dataHome(), "cortexkit", "aft");
22
+ }
package/src/tui/index.tsx CHANGED
@@ -4,10 +4,14 @@
4
4
  import type { TuiPlugin, TuiPluginApi, TuiThemeCurrent } from "@opencode-ai/plugin/tui";
5
5
  import { createMemo, createSignal, onCleanup } from "solid-js";
6
6
 
7
- import packageJson from "../../package.json";
7
+ import { version as packageVersion } from "../../package.json";
8
8
  import { AftRpcClient } from "../shared/rpc-client";
9
9
  import { type AftStatusSnapshot, coerceAftStatus, formatBytes } from "../shared/status";
10
- import { createAftSidebarSlot, formatCompressionSidebarRows } from "./sidebar";
10
+ import {
11
+ createAftSidebarSlot,
12
+ formatCompressionSidebarRows,
13
+ resolveTuiStorageDir,
14
+ } from "./sidebar";
11
15
 
12
16
  // The TUI talks to the server plugin via AftRpcClient. The client reads the
13
17
  // JSON port file written by AftRpcServer ({ port, token }) and includes that
@@ -23,16 +27,7 @@ function getRpcClient(directory: string): AftRpcClient {
23
27
  let client = rpcClients.get(directory);
24
28
  if (client) return client;
25
29
 
26
- // v0.27 moved AFT storage to the CortexKit root. The TUI plugin must use
27
- // the same path as the server plugin (`resolveCortexKitStorageRoot()`),
28
- // otherwise it polls stale legacy port files written by older versions
29
- // and never picks up the live RPC server, leaving the sidebar/dialog
30
- // stuck on the "AFT is starting up" placeholder forever.
31
- const home = process.env.HOME || process.env.USERPROFILE || "";
32
- const dataHome = process.env.XDG_DATA_HOME || `${home}/.local/share`;
33
- const storageDir = `${dataHome}/cortexkit/aft`;
34
-
35
- client = new AftRpcClient(storageDir, directory);
30
+ client = new AftRpcClient(resolveTuiStorageDir(), directory);
36
31
  rpcClients.set(directory, client);
37
32
  return client;
38
33
  }
@@ -142,18 +137,45 @@ const StatusDialog = (props: StatusDialogProps) => {
142
137
  const [status, setStatus] = createSignal<AftStatusSnapshot | null>(props.initial);
143
138
  const [error, setError] = createSignal<string | null>(props.initialError);
144
139
 
145
- const timer = setInterval(async () => {
140
+ let pollGeneration = 0;
141
+ let pollController: AbortController | null = null;
142
+ const pollStatus = async () => {
143
+ if (pollController) return;
144
+
145
+ const controller = new AbortController();
146
+ const requestGeneration = ++pollGeneration;
147
+ pollController = controller;
148
+
146
149
  try {
147
- const response = await props.client.call("status", { sessionID: props.sessionID });
150
+ const response = await props.client.call(
151
+ "status",
152
+ { sessionID: props.sessionID },
153
+ { signal: controller.signal },
154
+ );
155
+ if (controller.signal.aborted || requestGeneration !== pollGeneration) return;
148
156
  if ((response as Record<string, unknown>).success !== false) {
149
157
  setStatus(coerceAftStatus(response as Record<string, unknown>));
150
158
  setError(null);
151
159
  }
152
160
  } catch {
161
+ if (controller.signal.aborted || requestGeneration !== pollGeneration) return;
153
162
  // transient — keep showing last good snapshot
163
+ } finally {
164
+ if (pollController === controller) pollController = null;
154
165
  }
166
+ };
167
+
168
+ const timer = setInterval(() => {
169
+ void pollStatus();
155
170
  }, POLL_INTERVAL_MS);
156
- onCleanup(() => clearInterval(timer));
171
+ onCleanup(() => {
172
+ clearInterval(timer);
173
+ pollGeneration++;
174
+ if (pollController) {
175
+ pollController.abort();
176
+ pollController = null;
177
+ }
178
+ });
157
179
 
158
180
  // Visual cache-role badge: main is accent, worktree is warning,
159
181
  // not_initialized is muted. Matches the sidebar convention.
@@ -182,7 +204,7 @@ const StatusDialog = (props: StatusDialogProps) => {
182
204
  <b>⚡ AFT Status</b>
183
205
  </text>
184
206
  {status()?.cache_role !== "not_initialized" && (
185
- <text fg={t().textMuted}>v{status()?.version ?? packageJson.version}</text>
207
+ <text fg={t().textMuted}>v{status()?.version ?? packageVersion}</text>
186
208
  )}
187
209
  </box>
188
210
 
@@ -602,7 +624,7 @@ const tui: TuiPlugin = async (api) => {
602
624
  // command palette entry so the sidebar is available immediately when the
603
625
  // user opens their first session.
604
626
  try {
605
- api.slots.register(createAftSidebarSlot(api, packageJson.version));
627
+ api.slots.register(createAftSidebarSlot(api, packageVersion));
606
628
  } catch {
607
629
  // Older OpenCode TUI hosts may not implement api.slots; fall through
608
630
  // and keep the slash command working.