@cortexkit/aft-opencode 0.30.0 → 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.
- package/dist/bg-notifications.d.ts +2 -0
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/cache.d.ts +5 -16
- package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/index.d.ts +1 -1
- package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1005 -632
- package/dist/notifications.d.ts +8 -5
- package/dist/notifications.d.ts.map +1 -1
- package/dist/shared/last-assistant-model.d.ts +5 -6
- package/dist/shared/last-assistant-model.d.ts.map +1 -1
- package/dist/shared/live-server-client.d.ts +24 -22
- package/dist/shared/live-server-client.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +10 -3
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/storage-paths.d.ts +2 -0
- package/dist/shared/storage-paths.d.ts.map +1 -0
- package/dist/tools/_shared.d.ts +39 -0
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/ast.d.ts.map +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash_watch.d.ts +4 -0
- package/dist/tools/bash_watch.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/lsp.d.ts.map +1 -1
- package/dist/tools/navigation.d.ts.map +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/semantic.d.ts.map +1 -1
- package/dist/tui.js +222 -123
- package/package.json +8 -8
- package/src/shared/last-assistant-model.ts +9 -27
- package/src/shared/live-server-client.ts +80 -48
- package/src/shared/rpc-client.ts +132 -28
- package/src/shared/storage-paths.ts +22 -0
- package/src/tui/index.tsx +39 -17
- package/src/tui/sidebar.tsx +94 -27
|
@@ -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
|
|
27
|
-
* the wake path silently uses the in-process
|
|
28
|
-
* which keeps wakes flowing (at the
|
|
29
|
-
* bug) instead of producing no
|
|
30
|
-
* to relaunch with a different
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
-
*
|
|
104
|
-
* live
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
//
|
|
120
|
-
// (e.g. an internal IPC port that
|
|
121
|
-
// all paths with 404 —
|
|
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
|
-
|
|
156
|
+
reachable = res.ok || res.status === 401 || res.status === 403;
|
|
129
157
|
} catch {
|
|
130
|
-
|
|
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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* to
|
|
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
|
-
|
|
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
|
|
163
|
-
* `getLiveServerClient(serverUrl, directory)` and POST through
|
|
164
|
-
* HTTP listener. `false` means fall back to the in-process client
|
|
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
|
|
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
|
-
|
|
204
|
+
liveServerWakeAvailableByServerUrl.clear();
|
|
205
|
+
legacyLiveServerWakeAvailable = false;
|
|
174
206
|
}
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -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
|
|
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(
|
|
69
|
-
method
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
104
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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(
|
|
207
|
+
private parsePortFile(filePath: string): ParsedPortInfo | null {
|
|
149
208
|
try {
|
|
150
|
-
const content = readFileSync(
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(() =>
|
|
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 ??
|
|
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,
|
|
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.
|