@cortexkit/aft-opencode 0.27.1 → 0.28.1
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 +10 -0
- package/dist/bg-notifications.d.ts.map +1 -1
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24680 -2053
- package/dist/lsp-auto-install.d.ts.map +1 -1
- package/dist/lsp-npm-table.d.ts +14 -0
- package/dist/lsp-npm-table.d.ts.map +1 -1
- package/dist/lsp-project-relevance.d.ts +13 -0
- package/dist/lsp-project-relevance.d.ts.map +1 -1
- package/dist/notifications.d.ts +1 -1
- package/dist/notifications.d.ts.map +1 -1
- package/dist/shared/live-server-client.d.ts +60 -0
- package/dist/shared/live-server-client.d.ts.map +1 -0
- package/dist/shared/rpc-client.d.ts +18 -8
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +3 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +19 -1
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/tools/hoisted.d.ts.map +1 -1
- package/dist/tui.js +194 -168
- package/dist/workflow-hints.d.ts +5 -1
- package/dist/workflow-hints.d.ts.map +1 -1
- package/package.json +9 -8
- package/src/shared/live-server-client.ts +132 -0
- package/src/shared/rpc-client.ts +97 -76
- package/src/shared/rpc-server.ts +8 -3
- package/src/shared/rpc-utils.ts +23 -1
- package/src/tui/index.tsx +21 -6
- package/src/tui/sidebar.tsx +92 -66
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workaround helper for the OpenCode plugin promptAsync runner-split bug
|
|
3
|
+
* (https://github.com/anomalyco/opencode/issues/28202).
|
|
4
|
+
*
|
|
5
|
+
* OpenCode's plugin-provided `input.client` is constructed with
|
|
6
|
+
* `fetch: async (...args) => Server.Default().app.fetch(...args)`, which
|
|
7
|
+
* routes requests through `HttpApiApp.webHandler()` and a SEPARATE Effect
|
|
8
|
+
* `memoMap` from the one used by the live HTTP listener. Since
|
|
9
|
+
* `SessionRunState` is a per-memo-map in-memory layer, plugin-origin
|
|
10
|
+
* `promptAsync` calls observe an "idle" runner while the live UI turn is
|
|
11
|
+
* still running. The result is that `ensureRunning` fails to coalesce and
|
|
12
|
+
* OpenCode persists multiple assistant children under a single synthetic
|
|
13
|
+
* user parent — what users see as duplicate "stop" messages after every
|
|
14
|
+
* background-bash completion reminder.
|
|
15
|
+
*
|
|
16
|
+
* The workaround is to bypass `input.client` for the wake path and build
|
|
17
|
+
* a separate `createOpencodeClient` configured to hit `input.serverUrl`
|
|
18
|
+
* via `globalThis.fetch`. That client enters the same live listener the
|
|
19
|
+
* UI uses, so the active session's `SessionRunState` is the one that
|
|
20
|
+
* resolves `ensureRunning` and overlapping turns coalesce correctly.
|
|
21
|
+
*
|
|
22
|
+
* Tracked upstream as anomalyco/opencode#28202. When OpenCode fixes the
|
|
23
|
+
* runtime split, this helper and its single consumer in `bg-notifications.ts`
|
|
24
|
+
* can be deleted and the wake path can go back to `input.client`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
28
|
+
|
|
29
|
+
export type LiveServerClient = ReturnType<typeof createOpencodeClient>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cache key is `${serverUrl}|${directory}`. Both are stable per OpenCode
|
|
33
|
+
* session/project pair, so one client is reused across many wakes. We don't
|
|
34
|
+
* key on `serverUrl + auth header` because the auth env vars are server-wide
|
|
35
|
+
* — if they change we'd want a fresh client anyway; in practice they're set
|
|
36
|
+
* once at process start.
|
|
37
|
+
*/
|
|
38
|
+
const clientCache = new Map<string, LiveServerClient>();
|
|
39
|
+
|
|
40
|
+
function cacheKey(serverUrl: string, directory: string): string {
|
|
41
|
+
return `${serverUrl}|${directory}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the Basic-auth header OpenCode's server expects when
|
|
46
|
+
* `OPENCODE_SERVER_PASSWORD` is set. Read at call time (not at module load)
|
|
47
|
+
* so test setup can mutate `process.env` between cases.
|
|
48
|
+
*/
|
|
49
|
+
function serverAuthHeaders(): Record<string, string> | undefined {
|
|
50
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
51
|
+
if (!password) return undefined;
|
|
52
|
+
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
|
|
53
|
+
return {
|
|
54
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Return a cached `createOpencodeClient` pointed at the live HTTP listener
|
|
60
|
+
* for the given `(serverUrl, directory)` pair. One client object is reused
|
|
61
|
+
* across many wakes for a given session.
|
|
62
|
+
*
|
|
63
|
+
* The `fetch` is bound to `globalThis.fetch` explicitly. Without this, the
|
|
64
|
+
* SDK would fall back to `globalThis.fetch` anyway in normal Node runtimes,
|
|
65
|
+
* but we set it on purpose so anyone reading this code (or grepping for the
|
|
66
|
+
* bug fix) can see that we intentionally chose the live HTTP transport.
|
|
67
|
+
*/
|
|
68
|
+
export function getLiveServerClient(serverUrl: string, directory: string): LiveServerClient {
|
|
69
|
+
const key = cacheKey(serverUrl, directory);
|
|
70
|
+
const cached = clientCache.get(key);
|
|
71
|
+
if (cached) return cached;
|
|
72
|
+
const client = createOpencodeClient({
|
|
73
|
+
baseUrl: serverUrl,
|
|
74
|
+
directory,
|
|
75
|
+
headers: serverAuthHeaders(),
|
|
76
|
+
fetch: globalThis.fetch,
|
|
77
|
+
});
|
|
78
|
+
clientCache.set(key, client);
|
|
79
|
+
return client;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Test helper — drop the cache between cases so each test starts clean. */
|
|
83
|
+
export function __resetLiveServerClientCacheForTests(): void {
|
|
84
|
+
clientCache.clear();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True when the current runtime is Bun. OpenCode's TUI ships with Bun;
|
|
89
|
+
* the Electron Desktop app ships with Node. We use this to gate the
|
|
90
|
+
* `--port 0` nudge: Desktop is unaffected by anomalyco/opencode#28202
|
|
91
|
+
* because Node's webidl polyfill is complete, so undici v8 (and the live
|
|
92
|
+
* HTTP server) work without additional flags.
|
|
93
|
+
*/
|
|
94
|
+
export function isBunRuntime(): boolean {
|
|
95
|
+
return typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Probe whether `serverUrl` accepts a connection within `timeoutMs`.
|
|
100
|
+
* Returns `true` for any HTTP response (including 4xx / 5xx) since the
|
|
101
|
+
* goal is to confirm the listener exists. Returns `false` on connection
|
|
102
|
+
* refused, DNS failure, timeout, or undefined URL.
|
|
103
|
+
*
|
|
104
|
+
* Used at plugin init under Bun to detect TUI sessions started without
|
|
105
|
+
* `opencode --port 0` — those bind an internal-only listener that 404s
|
|
106
|
+
* for `/session/...` endpoints and breaks AFT's wake path.
|
|
107
|
+
*/
|
|
108
|
+
export async function probeServerReachable(
|
|
109
|
+
serverUrl: string | undefined,
|
|
110
|
+
timeoutMs = 1500,
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
if (!serverUrl) return false;
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
115
|
+
try {
|
|
116
|
+
// Hit a path that actually exists on the OpenCode HTTP API so a
|
|
117
|
+
// 200 confirms the API server is up, not just any random listener
|
|
118
|
+
// (e.g. an internal IPC port that happens to accept TCP but rejects
|
|
119
|
+
// all paths with 404 — which is exactly what TUI binds without
|
|
120
|
+
// `--port 0`).
|
|
121
|
+
const probeUrl = new URL("/session", serverUrl).toString();
|
|
122
|
+
const res = await globalThis.fetch(probeUrl, {
|
|
123
|
+
method: "GET",
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
});
|
|
126
|
+
return res.status >= 200 && res.status < 500;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
2
3
|
import { warn } from "../logger";
|
|
3
|
-
import { rpcPortFilePath } from "./rpc-utils";
|
|
4
|
+
import { rpcPortFileDir, rpcPortFilePath } from "./rpc-utils";
|
|
4
5
|
|
|
5
6
|
const MAX_RETRIES = 10;
|
|
6
7
|
const RETRY_DELAY_MS = 500;
|
|
7
8
|
const REQUEST_TIMEOUT_MS = 5000;
|
|
8
9
|
|
|
10
|
+
type PortInfo = { port: number; token: string | null };
|
|
11
|
+
|
|
9
12
|
export class AftRpcClient {
|
|
10
13
|
private port: number | null = null;
|
|
11
14
|
private token: string | null = null;
|
|
12
|
-
private
|
|
13
|
-
private
|
|
15
|
+
private portsDir: string;
|
|
16
|
+
private legacyPortFile: string;
|
|
14
17
|
|
|
15
18
|
constructor(storageDir: string, directory: string) {
|
|
16
|
-
this.
|
|
19
|
+
this.portsDir = rpcPortFileDir(storageDir, directory);
|
|
20
|
+
this.legacyPortFile = rpcPortFilePath(storageDir, directory);
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/** Call an RPC method. Retries port resolution if the server isn't ready yet. */
|
|
@@ -21,111 +25,129 @@ export class AftRpcClient {
|
|
|
21
25
|
method: string,
|
|
22
26
|
params: Record<string, unknown> = {},
|
|
23
27
|
): Promise<T> {
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
// Try ALL discovered ports for this project (OpenCode TUI under --port 0
|
|
29
|
+
// loads our plugin twice in the same process, so two RPC servers listen
|
|
30
|
+
// and we have to try both — only one's bridge is actually warm).
|
|
31
|
+
const infos = await this.resolvePortInfos();
|
|
32
|
+
if (infos.length === 0) {
|
|
26
33
|
throw new Error("AFT RPC server not available");
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
// First pass: try every port. Prefer responses that look like "warm
|
|
37
|
+
// bridge" output (i.e. not the synthetic `status: "not_initialized"`
|
|
38
|
+
// placeholder served when this instance's bridge hasn't been spawned).
|
|
39
|
+
let placeholder: T | null = null;
|
|
40
|
+
let lastError: unknown = null;
|
|
41
|
+
for (const info of infos) {
|
|
42
|
+
try {
|
|
43
|
+
const result = await this.callOne<T>(method, params, info);
|
|
44
|
+
if (this.looksLikePlaceholder(result)) {
|
|
45
|
+
placeholder = result; // remember but keep trying
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Warm response — cache this port for subsequent calls.
|
|
49
|
+
this.port = info.port;
|
|
50
|
+
this.token = info.token;
|
|
51
|
+
return result;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
lastError = err;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// All ports returned placeholder OR failed. Use placeholder if we have
|
|
58
|
+
// one (sidebar then shows the lazy-spawn UI); otherwise rethrow last error.
|
|
59
|
+
if (placeholder !== null) return placeholder;
|
|
60
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
30
61
|
}
|
|
31
62
|
|
|
32
|
-
private async
|
|
63
|
+
private async callOne<T>(
|
|
33
64
|
method: string,
|
|
34
65
|
params: Record<string, unknown>,
|
|
35
|
-
info:
|
|
36
|
-
retryOnConnectionFailure: boolean,
|
|
66
|
+
info: PortInfo,
|
|
37
67
|
): Promise<T> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
body: JSON.stringify({ ...params, token: info.token }),
|
|
44
|
-
});
|
|
45
|
-
} catch (err) {
|
|
46
|
-
if (!retryOnConnectionFailure) throw err;
|
|
47
|
-
return this.retryAfterReset<T>(method, params, err);
|
|
48
|
-
}
|
|
49
|
-
|
|
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
|
+
});
|
|
50
73
|
if (!response.ok) {
|
|
51
74
|
const text = await response.text();
|
|
52
|
-
if (response.status >= 500 && retryOnConnectionFailure) {
|
|
53
|
-
return this.retryAfterReset<T>(method, params, `HTTP ${response.status}: ${text}`);
|
|
54
|
-
}
|
|
55
75
|
throw new Error(`RPC ${method} failed (${response.status}): ${text}`);
|
|
56
76
|
}
|
|
57
|
-
|
|
58
77
|
return (await response.json()) as T;
|
|
59
78
|
}
|
|
60
79
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!refreshed) {
|
|
71
|
-
throw new Error("AFT RPC server not available after port refresh");
|
|
72
|
-
}
|
|
73
|
-
return this.callResolved<T>(method, params, refreshed, false);
|
|
80
|
+
/**
|
|
81
|
+
* Heuristic for "this response is the lazy-spawn placeholder, not the real
|
|
82
|
+
* data." We treat any `not_initialized` status as a placeholder so the
|
|
83
|
+
* client knows to try the next port (the warm one).
|
|
84
|
+
*/
|
|
85
|
+
private looksLikePlaceholder<T>(result: T): boolean {
|
|
86
|
+
if (!result || typeof result !== "object") return false;
|
|
87
|
+
const status = (result as Record<string, unknown>).status;
|
|
88
|
+
return status === "not_initialized";
|
|
74
89
|
}
|
|
75
90
|
|
|
76
|
-
/** Check if
|
|
91
|
+
/** Check if any RPC server is reachable. */
|
|
77
92
|
async isAvailable(): Promise<boolean> {
|
|
78
93
|
try {
|
|
79
|
-
const
|
|
80
|
-
return
|
|
94
|
+
const infos = await this.resolvePortInfos();
|
|
95
|
+
return infos.length > 0;
|
|
81
96
|
} catch {
|
|
82
97
|
return false;
|
|
83
98
|
}
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return { port: this.port, token: this.token };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (this.port) {
|
|
96
|
-
const alive = await this.healthCheck(this.port);
|
|
97
|
-
if (alive) {
|
|
98
|
-
this.healthChecked = true;
|
|
99
|
-
return { port: this.port, token: this.token };
|
|
100
|
-
}
|
|
101
|
-
this.port = null;
|
|
102
|
-
this.token = null;
|
|
103
|
-
this.healthChecked = false;
|
|
104
|
-
}
|
|
105
|
-
|
|
101
|
+
/**
|
|
102
|
+
* 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).
|
|
105
|
+
*/
|
|
106
|
+
private async resolvePortInfos(): Promise<PortInfo[]> {
|
|
106
107
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
const alive =
|
|
110
|
-
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return info;
|
|
108
|
+
const infos = this.readAllPortFiles();
|
|
109
|
+
if (infos.length > 0) {
|
|
110
|
+
const alive: PortInfo[] = [];
|
|
111
|
+
for (const info of infos) {
|
|
112
|
+
if (await this.healthCheck(info.port)) {
|
|
113
|
+
alive.push(info);
|
|
114
|
+
}
|
|
115
115
|
}
|
|
116
|
+
if (alive.length > 0) return alive;
|
|
116
117
|
}
|
|
117
|
-
|
|
118
118
|
if (attempt < MAX_RETRIES - 1) {
|
|
119
119
|
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
private readAllPortFiles(): PortInfo[] {
|
|
126
|
+
const collected: PortInfo[] = [];
|
|
127
|
+
// Per-instance directory (v0.28.2+): one file per plugin load.
|
|
128
|
+
if (existsSync(this.portsDir)) {
|
|
129
|
+
try {
|
|
130
|
+
const entries = readdirSync(this.portsDir);
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (!entry.endsWith(".json")) continue;
|
|
133
|
+
const info = this.parsePortFile(join(this.portsDir, entry));
|
|
134
|
+
if (info) collected.push(info);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore read errors
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// 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
|
+
}
|
|
145
|
+
return collected;
|
|
124
146
|
}
|
|
125
147
|
|
|
126
|
-
private
|
|
148
|
+
private parsePortFile(path: string): PortInfo | null {
|
|
127
149
|
try {
|
|
128
|
-
const content = readFileSync(
|
|
150
|
+
const content = readFileSync(path, "utf-8").trim();
|
|
129
151
|
let port: number;
|
|
130
152
|
let token: string | null;
|
|
131
153
|
if (content.startsWith("{")) {
|
|
@@ -170,6 +192,5 @@ export class AftRpcClient {
|
|
|
170
192
|
reset(): void {
|
|
171
193
|
this.port = null;
|
|
172
194
|
this.token = null;
|
|
173
|
-
this.healthChecked = false;
|
|
174
195
|
}
|
|
175
196
|
}
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
-
import { dirname } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { log, warn } from "../logger";
|
|
6
|
-
import {
|
|
6
|
+
import { rpcPortFileDir } from "./rpc-utils";
|
|
7
7
|
|
|
8
8
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
9
9
|
|
|
@@ -13,9 +13,14 @@ export class AftRpcServer {
|
|
|
13
13
|
private token: string | null = null;
|
|
14
14
|
private handlers = new Map<string, RpcHandler>();
|
|
15
15
|
private portFilePath: string;
|
|
16
|
+
private portsDir: string;
|
|
17
|
+
/** Unique per-instance ID — distinguishes our entry from duplicate plugin loads. */
|
|
18
|
+
private instanceId: string;
|
|
16
19
|
|
|
17
20
|
constructor(storageDir: string, directory: string) {
|
|
18
|
-
this.
|
|
21
|
+
this.portsDir = rpcPortFileDir(storageDir, directory);
|
|
22
|
+
this.instanceId = randomBytes(8).toString("hex");
|
|
23
|
+
this.portFilePath = join(this.portsDir, `${this.instanceId}.json`);
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
/** Register an RPC method handler. */
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -13,9 +13,31 @@ export function projectHash(directory: string): string {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* Legacy per-project RPC port file path (single file).
|
|
17
|
+
*
|
|
18
|
+
* Kept exported for backward-compatibility readers — when an older plugin
|
|
19
|
+
* instance is running alongside a newer one, the older one still writes
|
|
20
|
+
* to this path. New code prefers `rpcPortFileDir` (one file per instance)
|
|
21
|
+
* so that two plugin instances under `opencode --port 0` don't overwrite
|
|
22
|
+
* each other's port info. The client falls back to the legacy file if
|
|
23
|
+
* the new directory has no entries.
|
|
17
24
|
*/
|
|
18
25
|
export function rpcPortFilePath(storageDir: string, directory: string): string {
|
|
19
26
|
const hash = projectHash(directory);
|
|
20
27
|
return join(storageDir, "rpc", hash, "port");
|
|
21
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-project RPC port directory. Each plugin instance writes a file
|
|
32
|
+
* `<instance-id>.json` into this directory so the client can discover
|
|
33
|
+
* ALL active plugin instances (e.g. the two created by OpenCode TUI when
|
|
34
|
+
* launched with `--port 0`). The client tries each port and uses the
|
|
35
|
+
* first one whose bridge is warm.
|
|
36
|
+
*
|
|
37
|
+
* Replaces the single `port` file used pre-v0.28.2 (which suffered from
|
|
38
|
+
* last-write-wins racing under `--port 0`).
|
|
39
|
+
*/
|
|
40
|
+
export function rpcPortFileDir(storageDir: string, directory: string): string {
|
|
41
|
+
const hash = projectHash(directory);
|
|
42
|
+
return join(storageDir, "rpc", hash, "ports");
|
|
43
|
+
}
|
package/src/tui/index.tsx
CHANGED
|
@@ -174,12 +174,16 @@ const StatusDialog = (props: StatusDialogProps) => {
|
|
|
174
174
|
paddingTop={1}
|
|
175
175
|
paddingBottom={1}
|
|
176
176
|
>
|
|
177
|
-
{/* Title
|
|
177
|
+
{/* Title. Hide version while the lazy-spawn placeholder is showing — users
|
|
178
|
+
read `vunknown` next to "AFT Status" as broken state instead of "AFT
|
|
179
|
+
has not been used yet for this project". */}
|
|
178
180
|
<box justifyContent="center" width="100%" marginBottom={1} flexDirection="row" gap={2}>
|
|
179
181
|
<text fg={t().accent}>
|
|
180
182
|
<b>⚡ AFT Status</b>
|
|
181
183
|
</text>
|
|
182
|
-
|
|
184
|
+
{status()?.cache_role !== "not_initialized" && (
|
|
185
|
+
<text fg={t().textMuted}>v{status()?.version ?? packageJson.version}</text>
|
|
186
|
+
)}
|
|
183
187
|
</box>
|
|
184
188
|
|
|
185
189
|
{/* Error / not-yet-ready state */}
|
|
@@ -197,7 +201,8 @@ const StatusDialog = (props: StatusDialogProps) => {
|
|
|
197
201
|
{status()?.cache_role === "not_initialized" ? (
|
|
198
202
|
<box width="100%" marginTop={1} justifyContent="center">
|
|
199
203
|
<text fg={t().textMuted}>
|
|
200
|
-
{status()!.message ||
|
|
204
|
+
{status()!.message ||
|
|
205
|
+
"AFT bridge is now spawned lazily, information here will be populated after first tool call."}
|
|
201
206
|
</text>
|
|
202
207
|
</box>
|
|
203
208
|
) : null}
|
|
@@ -224,8 +229,10 @@ const StatusDialog = (props: StatusDialogProps) => {
|
|
|
224
229
|
</box>
|
|
225
230
|
) : null}
|
|
226
231
|
|
|
227
|
-
{/* 2-column body
|
|
228
|
-
|
|
232
|
+
{/* 2-column body. Gate on cache_role too so a synthetic not_initialized
|
|
233
|
+
snapshot doesn't render an empty grid of "unknown" / "—" rows
|
|
234
|
+
alongside the lazy-spawn placeholder message above. */}
|
|
235
|
+
{status() && status()!.cache_role !== "not_initialized" ? (
|
|
229
236
|
<box flexDirection="row" width="100%" gap={4}>
|
|
230
237
|
{/* Left column */}
|
|
231
238
|
<box flexDirection="column" flexGrow={1} flexBasis={0}>
|
|
@@ -540,16 +547,24 @@ async function showStartupNotifications(api: TuiPluginApi): Promise<void> {
|
|
|
540
547
|
show?: boolean;
|
|
541
548
|
version?: string;
|
|
542
549
|
features?: string[];
|
|
550
|
+
footer?: string;
|
|
543
551
|
};
|
|
544
552
|
|
|
545
553
|
if (announcement.show && announcement.version && announcement.features?.length) {
|
|
546
554
|
const featureText = announcement.features.map((f: string) => ` • ${f}`).join("\n");
|
|
555
|
+
// Blank-line separator distinguishes the persistent footer (Discord
|
|
556
|
+
// invite, etc.) from the version-specific bullets.
|
|
557
|
+
const hasFooter =
|
|
558
|
+
typeof announcement.footer === "string" && announcement.footer.trim().length > 0;
|
|
559
|
+
const message = hasFooter
|
|
560
|
+
? `What's new:\n\n${featureText}\n\n${announcement.footer}`
|
|
561
|
+
: `What's new:\n\n${featureText}`;
|
|
547
562
|
|
|
548
563
|
api.ui.dialog.replace(
|
|
549
564
|
() => (
|
|
550
565
|
<api.ui.DialogAlert
|
|
551
566
|
title={`AFT v${announcement.version}`}
|
|
552
|
-
message={
|
|
567
|
+
message={message}
|
|
553
568
|
onConfirm={() => {
|
|
554
569
|
// Mark as announced so it doesn't show again
|
|
555
570
|
void client.call("mark-announced", {});
|