@cortexkit/aft-opencode 0.36.0 → 0.37.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.
- package/dist/bg-notifications.d.ts +5 -1
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1594 -1159
- package/dist/shared/rpc-client.d.ts +13 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +7 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +23 -0
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/session-directory.d.ts +1 -0
- package/dist/shared/session-directory.d.ts.map +1 -1
- package/dist/status-bar-inject.d.ts +1 -1
- package/dist/status-bar-inject.d.ts.map +1 -1
- package/dist/tool-perf.d.ts +17 -0
- package/dist/tool-perf.d.ts.map +1 -0
- package/dist/tools/_shared.d.ts +1 -11
- package/dist/tools/_shared.d.ts.map +1 -1
- package/dist/tools/bash.d.ts +21 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/conflicts.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tools/reading.d.ts.map +1 -1
- package/dist/tools/refactoring.d.ts.map +1 -1
- package/dist/tools/safety.d.ts.map +1 -1
- package/dist/tools/semantic.d.ts.map +1 -1
- package/dist/tui.js +118 -95
- package/dist/workflow-hints.d.ts.map +1 -1
- package/package.json +8 -8
- package/src/shared/rpc-client.ts +79 -26
- package/src/shared/rpc-server.ts +64 -6
- package/src/shared/rpc-utils.ts +62 -0
- package/src/shared/session-directory.ts +56 -0
- package/src/tui/index.tsx +26 -2
- package/src/tui/sidebar.tsx +64 -1
- package/dist/metadata-store.d.ts +0 -29
- package/dist/metadata-store.d.ts.map +0 -1
- package/dist/tools/structure.d.ts +0 -8
- package/dist/tools/structure.d.ts.map +0 -1
package/src/shared/rpc-client.ts
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import { rpcPortFileDir, rpcPortFilePath } from "./rpc-utils";
|
|
3
|
+
import { isPidAlive, parseRpcPortRecord, rpcPortFileDir, rpcPortFilePath } from "./rpc-utils";
|
|
5
4
|
|
|
6
5
|
const MAX_RETRIES = 10;
|
|
7
6
|
const RETRY_DELAY_MS = 500;
|
|
8
7
|
const REQUEST_TIMEOUT_MS = 5000;
|
|
9
8
|
|
|
10
9
|
type PortInfoSource = "instance" | "legacy";
|
|
11
|
-
type ParsedPortInfo = { port: number; token: string | null };
|
|
10
|
+
type ParsedPortInfo = { port: number; token: string | null; pid?: number; started_at?: number };
|
|
12
11
|
type PortInfo = ParsedPortInfo & { source: PortInfoSource; path?: string };
|
|
13
12
|
|
|
14
13
|
export interface AftRpcCallOptions {
|
|
15
14
|
signal?: AbortSignal;
|
|
15
|
+
/**
|
|
16
|
+
* Optional gate over WARM (non-placeholder) responses. With multiple RPC
|
|
17
|
+
* servers alive for one project hash (multi-project hosts, stale long-lived
|
|
18
|
+
* processes), a warm response can describe ANOTHER project's bridge
|
|
19
|
+
* (cross-project contamination). Return false to skip that response and
|
|
20
|
+
* keep trying other ports instead of returning it — so a stray server can't
|
|
21
|
+
* mask the right one. Skipped responses are never cached as the good port
|
|
22
|
+
* and never returned; if only strays and placeholders answer, the
|
|
23
|
+
* placeholder wins.
|
|
24
|
+
*/
|
|
25
|
+
accept?: (result: unknown) => boolean;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
function abortError(signal: AbortSignal): Error {
|
|
@@ -83,6 +93,11 @@ export class AftRpcClient {
|
|
|
83
93
|
placeholder = result; // remember but keep trying
|
|
84
94
|
continue;
|
|
85
95
|
}
|
|
96
|
+
if (options.accept && !options.accept(result)) {
|
|
97
|
+
// Stray warm response (e.g. another project's data) — skip it and
|
|
98
|
+
// keep scanning so it can't mask the right server.
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
86
101
|
// Warm response — cache this port for subsequent calls.
|
|
87
102
|
this.port = info.port;
|
|
88
103
|
this.token = info.token;
|
|
@@ -155,7 +170,7 @@ export class AftRpcClient {
|
|
|
155
170
|
const alive: PortInfo[] = [];
|
|
156
171
|
for (const info of infos) {
|
|
157
172
|
throwIfAborted(signal);
|
|
158
|
-
if (await this.healthCheck(info.port, signal)) {
|
|
173
|
+
if (await this.healthCheck(info.port, info.pid, signal)) {
|
|
159
174
|
this.clearPortFailure(info);
|
|
160
175
|
alive.push(info);
|
|
161
176
|
} else {
|
|
@@ -180,16 +195,39 @@ export class AftRpcClient {
|
|
|
180
195
|
collected.push(info);
|
|
181
196
|
};
|
|
182
197
|
|
|
183
|
-
// Per-instance directory (v0.28.2+): one file per plugin load.
|
|
198
|
+
// Per-instance directory (v0.28.2+): one file per plugin load. Each file now
|
|
199
|
+
// records the owning process pid; we skip (and reclaim) any whose process is
|
|
200
|
+
// dead so a poll doesn't wade through crash/restart leftovers, and we order
|
|
201
|
+
// newest-first so the freshest live server wins the port de-dupe (a stale
|
|
202
|
+
// file naming a reused port can no longer mask a fresh one with its old token).
|
|
184
203
|
if (existsSync(this.portsDir)) {
|
|
185
204
|
try {
|
|
186
|
-
const
|
|
187
|
-
for (const entry of
|
|
205
|
+
const live: PortInfo[] = [];
|
|
206
|
+
for (const entry of readdirSync(this.portsDir)) {
|
|
188
207
|
if (!entry.endsWith(".json")) continue;
|
|
189
208
|
const filePath = join(this.portsDir, entry);
|
|
190
209
|
const info = this.parsePortFile(filePath);
|
|
191
|
-
if (info)
|
|
210
|
+
if (!info) continue;
|
|
211
|
+
// Only reclaim when we can PROVE the owner is dead (pid present and not
|
|
212
|
+
// alive). Files without a pid (older format) are kept and fall through
|
|
213
|
+
// to the health check, since we can't prove they're dead.
|
|
214
|
+
if (info.pid !== undefined && !isPidAlive(info.pid)) {
|
|
215
|
+
this.reclaimDeadPortFile(filePath);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
live.push({ ...info, source: "instance", path: filePath });
|
|
192
219
|
}
|
|
220
|
+
// Newest first: files with a started_at sort before those without.
|
|
221
|
+
// Break ties deterministically (equal/absent started_at) by pid then
|
|
222
|
+
// port so selection is stable across reads rather than depending on
|
|
223
|
+
// directory iteration order.
|
|
224
|
+
live.sort(
|
|
225
|
+
(a, b) =>
|
|
226
|
+
(b.started_at ?? 0) - (a.started_at ?? 0) ||
|
|
227
|
+
(b.pid ?? 0) - (a.pid ?? 0) ||
|
|
228
|
+
(b.port ?? 0) - (a.port ?? 0),
|
|
229
|
+
);
|
|
230
|
+
for (const info of live) add(info);
|
|
193
231
|
} catch {
|
|
194
232
|
// ignore read errors
|
|
195
233
|
}
|
|
@@ -204,24 +242,19 @@ export class AftRpcClient {
|
|
|
204
242
|
return collected;
|
|
205
243
|
}
|
|
206
244
|
|
|
245
|
+
/** Delete a port file whose owning process is provably dead (best-effort). */
|
|
246
|
+
private reclaimDeadPortFile(filePath: string): void {
|
|
247
|
+
try {
|
|
248
|
+
unlinkSync(filePath);
|
|
249
|
+
} catch {
|
|
250
|
+
// best-effort; a concurrent writer may have already replaced it
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
207
254
|
private parsePortFile(filePath: string): ParsedPortInfo | null {
|
|
208
255
|
try {
|
|
209
|
-
const content = readFileSync(filePath, "utf-8")
|
|
210
|
-
|
|
211
|
-
let token: string | null;
|
|
212
|
-
if (content.startsWith("{")) {
|
|
213
|
-
const parsed = JSON.parse(content) as { port?: unknown; token?: unknown };
|
|
214
|
-
port = typeof parsed.port === "number" ? parsed.port : Number.NaN;
|
|
215
|
-
token = typeof parsed.token === "string" ? parsed.token : null;
|
|
216
|
-
} else {
|
|
217
|
-
warn("RPC port file uses legacy integer format; unauthenticated RPC is deprecated");
|
|
218
|
-
port = Number.parseInt(content, 10);
|
|
219
|
-
token = null;
|
|
220
|
-
}
|
|
221
|
-
if (Number.isNaN(port) || port <= 0 || port > 65535) {
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
return { port, token };
|
|
256
|
+
const content = readFileSync(filePath, "utf-8");
|
|
257
|
+
return parseRpcPortRecord(content);
|
|
225
258
|
} catch {
|
|
226
259
|
return null;
|
|
227
260
|
}
|
|
@@ -259,14 +292,34 @@ export class AftRpcClient {
|
|
|
259
292
|
}
|
|
260
293
|
}
|
|
261
294
|
|
|
262
|
-
private async healthCheck(
|
|
295
|
+
private async healthCheck(
|
|
296
|
+
port: number,
|
|
297
|
+
expectedPid: number | undefined,
|
|
298
|
+
signal?: AbortSignal,
|
|
299
|
+
): Promise<boolean> {
|
|
263
300
|
try {
|
|
264
301
|
const response = await this.fetchWithTimeout(
|
|
265
302
|
`http://127.0.0.1:${port}/health`,
|
|
266
303
|
{ method: "GET" },
|
|
267
304
|
signal,
|
|
268
305
|
);
|
|
269
|
-
|
|
306
|
+
if (!response.ok) return false;
|
|
307
|
+
// Identity check: the port file records the owning pid and `/health`
|
|
308
|
+
// echoes the live server's pid. If they disagree, the port was recycled
|
|
309
|
+
// by a *different* AFT server (or an unrelated localhost service that
|
|
310
|
+
// happens to answer /health), so this port file is stale — reject it so
|
|
311
|
+
// we don't POST this file's token to the wrong server. Only enforced when
|
|
312
|
+
// both sides expose a pid (older port files omit it).
|
|
313
|
+
if (expectedPid !== undefined) {
|
|
314
|
+
try {
|
|
315
|
+
const body = (await response.json()) as { pid?: unknown };
|
|
316
|
+
if (typeof body?.pid === "number" && body.pid !== expectedPid) return false;
|
|
317
|
+
} catch {
|
|
318
|
+
// Non-JSON / unexpected body: not a healthy AFT server we trust.
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return true;
|
|
270
323
|
} catch {
|
|
271
324
|
throwIfAborted(signal);
|
|
272
325
|
return false;
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
3
10
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
11
|
import { dirname, join } from "node:path";
|
|
5
12
|
import { log, warn } from "../logger";
|
|
6
|
-
import { rpcPortFileDir } from "./rpc-utils";
|
|
13
|
+
import { isPidAlive, parseRpcPortRecord, rpcPortFileDir } from "./rpc-utils";
|
|
7
14
|
|
|
8
15
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
9
16
|
|
|
@@ -53,12 +60,28 @@ export class AftRpcServer {
|
|
|
53
60
|
const dir = dirname(this.portFilePath);
|
|
54
61
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
55
62
|
const tmpPath = `${this.portFilePath}.tmp`;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
// Record pid + started_at so the client can skip files whose owning
|
|
64
|
+
// process is dead (without a health-check round-trip) and prefer the
|
|
65
|
+
// freshest live server, instead of accumulating crash/restart leftovers.
|
|
66
|
+
writeFileSync(
|
|
67
|
+
tmpPath,
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
port: this.port,
|
|
70
|
+
token: this.token,
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
started_at: Date.now(),
|
|
73
|
+
}),
|
|
74
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
75
|
+
);
|
|
60
76
|
renameSync(tmpPath, this.portFilePath);
|
|
61
77
|
log(`RPC server listening on 127.0.0.1:${this.port}`);
|
|
78
|
+
// Hygiene: sweep dead siblings while we're here. The client only
|
|
79
|
+
// reclaims dead-pid files on a cold port scan, and it caches the
|
|
80
|
+
// first warm port afterwards — so crash/restart leftovers piled up
|
|
81
|
+
// (dozens of dead files per project hash were observed). Server
|
|
82
|
+
// startup is the natural sweep point: it runs once per instance and
|
|
83
|
+
// already owns this directory.
|
|
84
|
+
this.sweepDeadPortFiles();
|
|
62
85
|
} catch (err) {
|
|
63
86
|
warn(`Failed to write RPC port file: ${err}`);
|
|
64
87
|
}
|
|
@@ -71,6 +94,41 @@ export class AftRpcServer {
|
|
|
71
94
|
});
|
|
72
95
|
}
|
|
73
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Remove sibling port files whose owning process is provably dead.
|
|
99
|
+
* Only files with a recorded pid that no longer maps to a live process are
|
|
100
|
+
* removed; pid-less (legacy) files and live entries are left untouched.
|
|
101
|
+
* Never touches our own freshly written file.
|
|
102
|
+
*/
|
|
103
|
+
private sweepDeadPortFiles(): void {
|
|
104
|
+
let entries: string[];
|
|
105
|
+
try {
|
|
106
|
+
entries = readdirSync(this.portsDir);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (!entry.endsWith(".json")) continue;
|
|
112
|
+
const filePath = join(this.portsDir, entry);
|
|
113
|
+
if (filePath === this.portFilePath) continue;
|
|
114
|
+
try {
|
|
115
|
+
const record = parseRpcPortRecord(readFileSync(filePath, "utf-8"));
|
|
116
|
+
// Unparsable file: stale tmp/corrupt leftover — remove. Parsable but
|
|
117
|
+
// pid-less (legacy): keep, we cannot prove the owner is dead.
|
|
118
|
+
if (record === null) {
|
|
119
|
+
unlinkSync(filePath);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (record.pid !== undefined && !isPidAlive(record.pid)) {
|
|
123
|
+
unlinkSync(filePath);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// Racing another instance's sweep or hitting a permission issue is
|
|
127
|
+
// fine — the file will be retried on the next server start.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
74
132
|
/** Stop the server and clean up port file. */
|
|
75
133
|
stop(): void {
|
|
76
134
|
if (this.server) {
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -41,3 +41,65 @@ export function rpcPortFileDir(storageDir: string, directory: string): string {
|
|
|
41
41
|
const hash = projectHash(directory);
|
|
42
42
|
return join(storageDir, "rpc", hash, "ports");
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Contents of a per-instance RPC port file. `pid` + `started_at` let the client
|
|
47
|
+
* skip files whose owning process is dead (no health-check round-trip) and pick
|
|
48
|
+
* the freshest live server, instead of wading through every crash/restart
|
|
49
|
+
* leftover. Older files carry only `{ port, token }` (no pid); those are treated
|
|
50
|
+
* as "can't prove dead" and fall back to the health-check path.
|
|
51
|
+
*/
|
|
52
|
+
export interface RpcPortRecord {
|
|
53
|
+
port: number;
|
|
54
|
+
token: string | null;
|
|
55
|
+
/** PID of the plugin process that owns this server, if recorded. */
|
|
56
|
+
pid?: number;
|
|
57
|
+
/** `Date.now()` when this server started, for newest-first ordering. */
|
|
58
|
+
started_at?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True if `pid` names a live process. `process.kill(pid, 0)` sends no signal. */
|
|
62
|
+
export function isPidAlive(pid: number | undefined): boolean {
|
|
63
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// EPERM = process exists but we can't signal it (still alive).
|
|
69
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a port file's contents into a record. Accepts the current JSON shape
|
|
75
|
+
* (`{ port, token, pid?, started_at? }`) and the legacy bare-integer format
|
|
76
|
+
* (unauthenticated, pre-v0.28.2). Returns `null` for unusable contents.
|
|
77
|
+
*/
|
|
78
|
+
export function parseRpcPortRecord(content: string): RpcPortRecord | null {
|
|
79
|
+
const trimmed = content.trim();
|
|
80
|
+
if (trimmed.length === 0) return null;
|
|
81
|
+
if (trimmed.startsWith("{")) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(trimmed) as {
|
|
84
|
+
port?: unknown;
|
|
85
|
+
token?: unknown;
|
|
86
|
+
pid?: unknown;
|
|
87
|
+
started_at?: unknown;
|
|
88
|
+
};
|
|
89
|
+
const port = typeof parsed.port === "number" ? parsed.port : Number.NaN;
|
|
90
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
91
|
+
return {
|
|
92
|
+
port,
|
|
93
|
+
token: typeof parsed.token === "string" ? parsed.token : null,
|
|
94
|
+
pid:
|
|
95
|
+
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) ? parsed.pid : undefined,
|
|
96
|
+
started_at: typeof parsed.started_at === "number" ? parsed.started_at : undefined,
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const port = Number.parseInt(trimmed, 10);
|
|
103
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
104
|
+
return { port, token: null };
|
|
105
|
+
}
|
|
@@ -152,7 +152,63 @@ export function warmSessionDirectory(
|
|
|
152
152
|
void getSessionDirectory(client, sessionId, fallbackDirectory);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Serve-time verification for cross-project bridge resolution.
|
|
157
|
+
*
|
|
158
|
+
* The RPC status handler may serve a bridge for a DIFFERENT project root than
|
|
159
|
+
* the requesting instance's own directory (the `opencode -s` resume case).
|
|
160
|
+
* That cross-directory path must never trust a possibly-stale cache entry:
|
|
161
|
+
* a wrong mapping makes the sidebar render another project's data (RPC
|
|
162
|
+
* contamination). This helper re-resolves the session's directory via a
|
|
163
|
+
* fresh SDK lookup and only returns a directory the SDK confirms RIGHT NOW.
|
|
164
|
+
*
|
|
165
|
+
* Results are memoized briefly (VERIFY_TTL_MS) so a 1.5s sidebar poll doesn't
|
|
166
|
+
* hammer the SDK; the TTL is short enough that stale mappings die quickly.
|
|
167
|
+
* Returns `null` when the lookup fails or the SDK reports no directory —
|
|
168
|
+
* callers must treat that as "do not serve cross-project data".
|
|
169
|
+
*/
|
|
170
|
+
const VERIFY_TTL_MS = 15_000;
|
|
171
|
+
const verifyCache = new Map<string, { directory: string | null; verifiedAt: number }>();
|
|
172
|
+
|
|
173
|
+
export async function verifySessionDirectory(
|
|
174
|
+
client: unknown,
|
|
175
|
+
sessionId: string,
|
|
176
|
+
): Promise<string | null> {
|
|
177
|
+
if (!sessionId) return null;
|
|
178
|
+
const hit = verifyCache.get(sessionId);
|
|
179
|
+
if (hit && Date.now() - hit.verifiedAt < VERIFY_TTL_MS) return hit.directory;
|
|
180
|
+
|
|
181
|
+
const c = client as OpenCodeClientShape;
|
|
182
|
+
const sessionApi = c?.session;
|
|
183
|
+
if (!sessionApi || typeof sessionApi.get !== "function") return null;
|
|
184
|
+
|
|
185
|
+
let dir: string | null = null;
|
|
186
|
+
try {
|
|
187
|
+
const result = await sessionApi.get({ path: { id: sessionId } });
|
|
188
|
+
const session: SessionInfo | undefined =
|
|
189
|
+
(result as { data?: SessionInfo } | undefined)?.data ?? (result as SessionInfo | undefined);
|
|
190
|
+
if (session && typeof session.directory === "string" && session.directory.length > 0) {
|
|
191
|
+
dir = session.directory;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Verification failure = do not serve cross-project data. Do NOT memoize
|
|
195
|
+
// failures: the next poll should retry (transient SDK errors must not
|
|
196
|
+
// stick the sidebar on placeholder for the TTL window).
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
verifyCache.set(sessionId, { directory: dir, verifiedAt: Date.now() });
|
|
201
|
+
if (verifyCache.size > CACHE_MAX_ENTRIES) {
|
|
202
|
+
const oldest = verifyCache.keys().next().value;
|
|
203
|
+
if (oldest !== undefined) verifyCache.delete(oldest);
|
|
204
|
+
}
|
|
205
|
+
// Keep the long-lived cache coherent with what the SDK just said.
|
|
206
|
+
if (dir !== null) setCache(sessionId, dir);
|
|
207
|
+
return dir;
|
|
208
|
+
}
|
|
209
|
+
|
|
155
210
|
/** Test-only: clear the cache between unit tests. */
|
|
156
211
|
export function _resetSessionDirectoryCacheForTest(): void {
|
|
157
212
|
cache.clear();
|
|
213
|
+
verifyCache.clear();
|
|
158
214
|
}
|
package/src/tui/index.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
createAftSidebarSlot,
|
|
18
18
|
formatCompressionSidebarRows,
|
|
19
|
+
isSnapshotForContext,
|
|
19
20
|
resolveTuiStorageDir,
|
|
20
21
|
shouldSuppressUninitializedDowngrade,
|
|
21
22
|
} from "./sidebar";
|
|
@@ -128,12 +129,30 @@ const R = (props: {
|
|
|
128
129
|
interface StatusDialogProps {
|
|
129
130
|
api: TuiPluginApi;
|
|
130
131
|
client: AftRpcClient;
|
|
132
|
+
directory: string;
|
|
131
133
|
sessionID: string;
|
|
132
134
|
initial: AftStatusSnapshot | null;
|
|
133
135
|
initialError: string | null;
|
|
134
136
|
onClose: () => void;
|
|
135
137
|
}
|
|
136
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Shared accept-gate for status RPC calls: skip warm responses that describe
|
|
141
|
+
* another project (cross-project contamination from multi-project hosts —
|
|
142
|
+
* see isSnapshotForContext) so they can't beat the right server's response.
|
|
143
|
+
*/
|
|
144
|
+
function statusAcceptGate(directory: string): (result: unknown) => boolean {
|
|
145
|
+
return (result) => {
|
|
146
|
+
const rec = result as Record<string, unknown>;
|
|
147
|
+
if (rec?.success === false) return true; // error envelopes handled by callers
|
|
148
|
+
return isSnapshotForContext(
|
|
149
|
+
coerceAftStatus(rec),
|
|
150
|
+
directory,
|
|
151
|
+
rec?.served_directory as string | undefined,
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
137
156
|
const StatusDialog = (props: StatusDialogProps) => {
|
|
138
157
|
const theme = createMemo(() => (props.api as any).theme.current as TuiThemeCurrent);
|
|
139
158
|
const t = () => theme();
|
|
@@ -157,7 +176,7 @@ const StatusDialog = (props: StatusDialogProps) => {
|
|
|
157
176
|
const response = await props.client.call(
|
|
158
177
|
"status",
|
|
159
178
|
{ sessionID: props.sessionID },
|
|
160
|
-
{ signal: controller.signal },
|
|
179
|
+
{ signal: controller.signal, accept: statusAcceptGate(props.directory) },
|
|
161
180
|
);
|
|
162
181
|
if (controller.signal.aborted || requestGeneration !== pollGeneration) return;
|
|
163
182
|
if ((response as Record<string, unknown>).success !== false) {
|
|
@@ -545,7 +564,11 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
|
|
|
545
564
|
let initial: AftStatusSnapshot | null = null;
|
|
546
565
|
let initialError: string | null = null;
|
|
547
566
|
try {
|
|
548
|
-
const response = await client.call(
|
|
567
|
+
const response = await client.call(
|
|
568
|
+
"status",
|
|
569
|
+
{ sessionID },
|
|
570
|
+
{ accept: statusAcceptGate(directory) },
|
|
571
|
+
);
|
|
549
572
|
if ((response as Record<string, unknown>).success !== false) {
|
|
550
573
|
initial = coerceAftStatus(response as Record<string, unknown>);
|
|
551
574
|
} else {
|
|
@@ -561,6 +584,7 @@ async function showStatusDialog(api: TuiPluginApi): Promise<void> {
|
|
|
561
584
|
<StatusDialog
|
|
562
585
|
api={api}
|
|
563
586
|
client={client}
|
|
587
|
+
directory={directory}
|
|
564
588
|
sessionID={sessionID}
|
|
565
589
|
initial={initial}
|
|
566
590
|
initialError={initialError}
|
package/src/tui/sidebar.tsx
CHANGED
|
@@ -259,6 +259,49 @@ export function shouldSuppressUninitializedDowngrade(
|
|
|
259
259
|
return incomingCacheRole === "not_initialized" && haveInitializedForContext;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Cross-project contamination belt. The RPC layer can hand back a snapshot
|
|
264
|
+
* describing a DIFFERENT project than the one this sidebar asked about — a
|
|
265
|
+
* multi-project host (Desktop / `opencode serve`) whose status handler
|
|
266
|
+
* resolved another project's warm bridge, including long-lived processes
|
|
267
|
+
* still running pre-fix plugin code. Rendering it shows another repo's
|
|
268
|
+
* indexes/health in this window.
|
|
269
|
+
*
|
|
270
|
+
* A mismatched project_root is acceptable ONLY when the serving handler says
|
|
271
|
+
* it resolved that directory DELIBERATELY: new servers attach
|
|
272
|
+
* `served_directory` (their own cwd, or the SDK-verified `opencode -s` resume
|
|
273
|
+
* directory) to every status response. That marker is handler-attached
|
|
274
|
+
* provenance — it cannot be faked by snapshot contents. We explicitly do NOT
|
|
275
|
+
* use `snapshot.session.id` here: Rust echoes the REQUESTED session id into
|
|
276
|
+
* the snapshot, so it matches even when the data came from another project's
|
|
277
|
+
* bridge (the hole that let contamination through this belt's first version).
|
|
278
|
+
*
|
|
279
|
+
* Rules:
|
|
280
|
+
* - placeholder/synthetic snapshots (no project_root) → accept (not data)
|
|
281
|
+
* - project_root (or canonical_root) matches the sidebar directory → accept
|
|
282
|
+
* - mismatched root AND served_directory matches a snapshot root → accept
|
|
283
|
+
* (deliberate, SDK-verified resume serve from a new server)
|
|
284
|
+
* - otherwise → reject (stray; includes everything old servers cross-serve)
|
|
285
|
+
*/
|
|
286
|
+
export function isSnapshotForContext(
|
|
287
|
+
snapshot: AftStatusSnapshot,
|
|
288
|
+
directory: string,
|
|
289
|
+
servedDirectory: string | undefined,
|
|
290
|
+
): boolean {
|
|
291
|
+
const stripSlash = (p: string) => p.replace(/\/+$/, "");
|
|
292
|
+
const roots = [snapshot.project_root, snapshot.canonical_root].filter(
|
|
293
|
+
(r): r is string => typeof r === "string" && r.length > 0,
|
|
294
|
+
);
|
|
295
|
+
if (roots.length === 0) return true; // placeholder / synthetic
|
|
296
|
+
const dir = stripSlash(directory);
|
|
297
|
+
if (roots.some((r) => stripSlash(r) === dir)) return true;
|
|
298
|
+
if (typeof servedDirectory === "string" && servedDirectory.length > 0) {
|
|
299
|
+
const served = stripSlash(servedDirectory);
|
|
300
|
+
return roots.some((r) => stripSlash(r) === served);
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
262
305
|
const SidebarContent = (props: {
|
|
263
306
|
api: TuiPluginApi;
|
|
264
307
|
sessionID: () => string;
|
|
@@ -330,12 +373,32 @@ const SidebarContent = (props: {
|
|
|
330
373
|
const response = await client.call(
|
|
331
374
|
"status",
|
|
332
375
|
{ sessionID: sid },
|
|
333
|
-
{
|
|
376
|
+
{
|
|
377
|
+
signal: controller.signal,
|
|
378
|
+
// With several RPC servers alive for this project hash, a stray
|
|
379
|
+
// warm response (another project's bridge) must not beat the right
|
|
380
|
+
// server or the placeholder — skip it at the port-scan level.
|
|
381
|
+
accept: (result) => {
|
|
382
|
+
const rec = result as Record<string, unknown>;
|
|
383
|
+
if (rec?.success === false) return true; // errors handled below
|
|
384
|
+
return isSnapshotForContext(
|
|
385
|
+
coerceAftStatus(rec),
|
|
386
|
+
directory,
|
|
387
|
+
rec?.served_directory as string | undefined,
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
},
|
|
334
391
|
);
|
|
335
392
|
if (controller.signal.aborted || requestGeneration !== generation) return;
|
|
336
393
|
if (currentDirectory() !== directory || props.sessionID() !== sid) return;
|
|
337
394
|
if (response && (response as Record<string, unknown>).success !== false) {
|
|
338
395
|
const snapshot = coerceAftStatus(response as Record<string, unknown>);
|
|
396
|
+
// Belt: never render a snapshot describing another project (see
|
|
397
|
+
// isSnapshotForContext). Keep whatever we currently show instead.
|
|
398
|
+
const servedDirectory = (response as Record<string, unknown>).served_directory as
|
|
399
|
+
| string
|
|
400
|
+
| undefined;
|
|
401
|
+
if (!isSnapshotForContext(snapshot, directory, servedDirectory)) return;
|
|
339
402
|
// Stale-while-revalidate: keep the last-good snapshot instead of
|
|
340
403
|
// flickering to the lazy-bridge placeholder on a transient
|
|
341
404
|
// not_initialized. See shouldSuppressUninitializedDowngrade.
|
package/dist/metadata-store.d.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pending tool metadata store.
|
|
3
|
-
*
|
|
4
|
-
* OpenCode's `fromPlugin()` wrapper always replaces plugin metadata with
|
|
5
|
-
* `{ truncated, outputPath }`, discarding title and custom metadata.
|
|
6
|
-
*
|
|
7
|
-
* This store captures metadata during execute(), then the `tool.execute.after`
|
|
8
|
-
* hook consumes it and merges it back before the final part is written.
|
|
9
|
-
*
|
|
10
|
-
* Flow:
|
|
11
|
-
* execute() → storeToolMetadata(sessionID, callID, data)
|
|
12
|
-
* fromPlugin() → overwrites metadata with { truncated }
|
|
13
|
-
* tool.execute.after → consumeToolMetadata(sessionID, callID) → merges back
|
|
14
|
-
*/
|
|
15
|
-
export interface PendingToolMetadata {
|
|
16
|
-
title?: string;
|
|
17
|
-
metadata?: Record<string, unknown>;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Store metadata to be restored after fromPlugin() overwrites it.
|
|
21
|
-
* Called from tool execute() functions.
|
|
22
|
-
*/
|
|
23
|
-
export declare function storeToolMetadata(sessionID: string, callID: string, data: PendingToolMetadata): void;
|
|
24
|
-
/**
|
|
25
|
-
* Consume stored metadata (one-time read, removes from store).
|
|
26
|
-
* Called from tool.execute.after hook.
|
|
27
|
-
*/
|
|
28
|
-
export declare function consumeToolMetadata(sessionID: string, callID: string): PendingToolMetadata | undefined;
|
|
29
|
-
//# sourceMappingURL=metadata-store.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"metadata-store.d.ts","sourceRoot":"","sources":["../src/metadata-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAmBD;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,mBAAmB,GACxB,IAAI,CAMN;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,GACb,mBAAmB,GAAG,SAAS,CASjC"}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { ToolDefinition } from "@opencode-ai/plugin";
|
|
2
|
-
import type { PluginContext } from "../types.js";
|
|
3
|
-
/**
|
|
4
|
-
* Tool definitions for scope-aware structure commands:
|
|
5
|
-
* add_member, add_derive, wrap_try_catch, add_decorator, add_struct_tags.
|
|
6
|
-
*/
|
|
7
|
-
export declare function structureTools(ctx: PluginContext): Record<string, ToolDefinition>;
|
|
8
|
-
//# sourceMappingURL=structure.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"structure.d.ts","sourceRoot":"","sources":["../../src/tools/structure.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAWjD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAyJjF"}
|