Skip to content

Commit 7ede60a

Browse files
committed
mason: unify aft-status TUI handling
1 parent aa6e80c commit 7ede60a

4 files changed

Lines changed: 364 additions & 28 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/// <reference path="../bun-test.d.ts" />
2+
3+
import { describe, expect, test } from "bun:test";
4+
import {
5+
drainNotifications,
6+
isTuiConnected,
7+
pushNotification,
8+
} from "../shared/rpc-notifications.js";
9+
10+
describe("rpc notifications", () => {
11+
test("keeps messages queued until the client acks their id", () => {
12+
const initial = drainNotifications(Number.MAX_SAFE_INTEGER);
13+
expect(initial).toEqual([]);
14+
15+
pushNotification("one", { ok: true }, "ses_1");
16+
const firstPoll = drainNotifications();
17+
expect(firstPoll).toHaveLength(1);
18+
expect(firstPoll[0]?.type).toBe("one");
19+
20+
const retryPoll = drainNotifications();
21+
expect(retryPoll.map((message) => message.id)).toEqual(firstPoll.map((message) => message.id));
22+
23+
const lastReceivedId = Math.max(...firstPoll.map((message) => message.id));
24+
expect(drainNotifications(lastReceivedId)).toEqual([]);
25+
});
26+
27+
test("scopes drain to the requesting session; other sessions' items survive", () => {
28+
// Drain everything left from prior tests.
29+
drainNotifications(Number.MAX_SAFE_INTEGER);
30+
31+
pushNotification("for-a", { action: "show-status-dialog" }, "ses_A");
32+
pushNotification("for-b", { action: "show-status-dialog" }, "ses_B");
33+
pushNotification("global", { action: "show-status-dialog" });
34+
35+
// Session A sees only its own item + the global one, never ses_B's.
36+
const aPoll = drainNotifications(0, "ses_A");
37+
expect(aPoll.map((message) => message.type).sort()).toEqual(["for-a", "global"]);
38+
39+
// Acking session A must NOT prune session B's still-unseen notification.
40+
const ackId = Math.max(...aPoll.map((message) => message.id));
41+
drainNotifications(ackId, "ses_A");
42+
const bPoll = drainNotifications(0, "ses_B");
43+
expect(bPoll.map((message) => message.type)).toContain("for-b");
44+
});
45+
46+
test("session-less drain still receives all items", () => {
47+
drainNotifications(Number.MAX_SAFE_INTEGER);
48+
pushNotification("x", { ok: true }, "ses_1");
49+
pushNotification("y", { ok: true }, "ses_2");
50+
const poll = drainNotifications(0);
51+
expect(poll.map((message) => message.type).sort()).toEqual(["x", "y"]);
52+
});
53+
54+
test("isTuiConnected is per-session: a TUI on session A does not mark session B connected", () => {
55+
// A TUI draining for tuiA must not make tuiB's producers think a TUI is
56+
// polling for tuiB (which would route tuiB's /aft-status to the dialog path
57+
// and lose it in the unrelated TUI). Use ids no other test drains so the
58+
// per-session window is unambiguous.
59+
drainNotifications(0, "ses_tuiA_only");
60+
expect(isTuiConnected("ses_tuiA_only")).toBe(true);
61+
expect(isTuiConnected("ses_tuiB_never_drained")).toBe(false);
62+
// The session-less (global) query still reports recent activity for legacy
63+
// callers that have no session context.
64+
expect(isTuiConnected()).toBe(true);
65+
});
66+
67+
test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
68+
drainNotifications(Number.MAX_SAFE_INTEGER);
69+
// One quiet session with a single pending dialog.
70+
pushNotification("quiet-dialog", { action: "show-status-dialog" }, "ses_quiet");
71+
// A noisy session floods well past the 100 cap.
72+
for (let i = 0; i < 200; i += 1) {
73+
pushNotification("noise", { i }, "ses_noisy");
74+
}
75+
// The quiet session's newest item must survive the eviction.
76+
const quietPoll = drainNotifications(0, "ses_quiet");
77+
expect(quietPoll.some((message) => message.type === "quiet-dialog")).toBe(true);
78+
});
79+
});

packages/opencode-plugin/src/index.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ import {
5656
import { maybeAppendConflictsHint } from "./shared/bash-hints.js";
5757
import { sendIgnoredMessage } from "./shared/ignored-message.js";
5858
import { disposeAllPtyTerminals } from "./shared/pty-cache.js";
59+
import {
60+
drainNotifications,
61+
isTuiConnected,
62+
pushNotification,
63+
} from "./shared/rpc-notifications.js";
5964
import { AftRpcServer } from "./shared/rpc-server.js";
6065
import {
6166
getSessionDirectory,
@@ -119,16 +124,44 @@ setActiveLogger(bridgeLogger);
119124
const STATUS_COMMAND = "aft-status";
120125
const SENTINEL_PREFIX = "__AFT_STATUS_";
121126

122-
function isTuiMode(): boolean {
123-
return process.env.OPENCODE_CLIENT === "cli";
124-
}
125-
126-
// Slash commands are registered by the TUI plugin (tui/index.tsx) via api.command.register()
127-
// which works in both TUI and Desktop modes. The server plugin only handles execution
128-
// via command.execute.before hook (for Desktop rendering as ignored message).
127+
// Effect HTTP plain-string TypeIds (NOT Symbols), verified against effect 4.x
128+
// source. Because the guards are `key in obj` checks on string keys, a hand-built
129+
// object carries them with NO effect import and NO version/realm coupling — it
130+
// works on the compiled OpenCode binary where effect is unreachable from an
131+
// external plugin's module resolution.
132+
const HTTP_SERVER_RESPONSE_TYPE_ID = "~effect/http/HttpServerResponse";
133+
const HTTP_COOKIES_TYPE_ID = "~effect/http/Cookies";
134+
const HTTP_BODY_TYPE_ID = "~effect/http/HttpBody";
135+
const ERROR_REPORTER_IGNORE = "~effect/ErrorReporter/ignore";
129136

137+
/** Prevent OpenCode from forwarding the handled command to the LLM.
138+
*
139+
* We throw a normal `Error` whose message preserves the legacy sentinel text
140+
* for hosts that do NOT recognize the Effect HTTP tags. The same Error also
141+
* duck-types an Effect `HttpServerResponse.empty({ status: 204 })`. On
142+
* OpenCode 1.17.x the HTTP error boundary recognizes it via the plain-string
143+
* TypeId (`isHttpServerResponse` = `"~effect/http/HttpServerResponse" in defect`),
144+
* skips the JSON-500 logging path, and writes it as a real 204 — so the handled
145+
* command neither reaches the LLM nor leaks an error into the TUI/log.
146+
*
147+
* Field shape is the minimal set `Response.toWeb` dereferences on the empty-body
148+
* path (status / statusText / headers / cookies.cookies / body._tag), traced
149+
* through effect 4.x. No effect import — all string keys + primitives.
150+
*
151+
* An official `command.execute.before` handled/cancel/noReply contract remains
152+
* the real fix; this is a duck-typed shim until then. */
130153
function throwSentinel(command: string): never {
131-
throw new Error(`${SENTINEL_PREFIX}${command.toUpperCase().replace(/-/g, "_")}_HANDLED__`);
154+
const sentinel = new Error(
155+
`${SENTINEL_PREFIX}${command.toUpperCase().replace(/-/g, "_")}_HANDLED__`,
156+
) as Error & Record<string, unknown>;
157+
sentinel[HTTP_SERVER_RESPONSE_TYPE_ID] = HTTP_SERVER_RESPONSE_TYPE_ID;
158+
sentinel[ERROR_REPORTER_IGNORE] = true;
159+
sentinel.status = 204;
160+
sentinel.statusText = undefined;
161+
sentinel.headers = {};
162+
sentinel.cookies = { [HTTP_COOKIES_TYPE_ID]: HTTP_COOKIES_TYPE_ID, cookies: {} };
163+
sentinel.body = { [HTTP_BODY_TYPE_ID]: HTTP_BODY_TYPE_ID, _tag: "Empty" };
164+
throw sentinel;
132165
}
133166

134167
// IMPORTANT — index.ts must export ONLY the plugin function as default.
@@ -697,6 +730,23 @@ async function initializePluginForDirectory(input: Parameters<Plugin>[0]) {
697730
}
698731
return { ...response, served_directory: servedDirectory };
699732
});
733+
734+
rpcServer.handle("consume-notifications", async (params) => {
735+
const rawLastReceivedId = Number(params.lastReceivedId ?? 0);
736+
// Scope drain to the TUI's active session so a notification tagged for a
737+
// different session (e.g. a dialog triggered by another client sharing this
738+
// process) is never delivered here. sessionId is optional for back-compat:
739+
// callers that omit it fall back to the previous unscoped behavior.
740+
const sessionId =
741+
typeof params.sessionId === "string" && params.sessionId.length > 0
742+
? params.sessionId
743+
: undefined;
744+
const messages = drainNotifications(
745+
Number.isFinite(rawLastReceivedId) ? rawLastReceivedId : 0,
746+
sessionId,
747+
);
748+
return { messages };
749+
});
700750
// Feature announcement — TUI plugin calls this on startup to show a dialog.
701751
// Uses ANNOUNCEMENT_VERSION (not PLUGIN_VERSION) so patch releases don't re-fire.
702752

@@ -1015,10 +1065,19 @@ async function initializePluginForDirectory(input: Parameters<Plugin>[0]) {
10151065
commandInput: { command: string; sessionID: string },
10161066
_output: unknown,
10171067
) => {
1018-
if (isTuiMode() || commandInput.command !== STATUS_COMMAND) {
1068+
if (commandInput.command !== STATUS_COMMAND) {
10191069
return;
10201070
}
10211071

1072+
if (isTuiConnected(commandInput.sessionID)) {
1073+
pushNotification(
1074+
"action",
1075+
{ action: "show-status-dialog", sessionId: commandInput.sessionID },
1076+
commandInput.sessionID,
1077+
);
1078+
throwSentinel(commandInput.command);
1079+
}
1080+
10221081
// Resolve the session's stored directory before picking a bridge —
10231082
// otherwise `/aft-status` from a `-s` session would target home cwd.
10241083
const sessionDir =
@@ -1093,9 +1152,8 @@ async function initializePluginForDirectory(input: Parameters<Plugin>[0]) {
10931152
// surface here was responsible for `S.provider`/`z.config` errors
10941153
// when this hook ran with an unexpected argument.
10951154
if (!config || typeof config !== "object") return;
1096-
// Register /aft-status for Desktop command palette.
1097-
// In TUI mode, the TUI plugin also registers it via api.command.register()
1098-
// which takes priority for dialog rendering.
1155+
// Register the only /aft-status slash entry. The TUI plugin registers a
1156+
// palette-only command and receives dialog requests via RPC notifications.
10991157
config.command = {
11001158
...(config.command ?? {}),
11011159
[STATUS_COMMAND]: {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* In-memory notification queue for server → TUI push.
3+
*
4+
* Also tracks whether a TUI client is actively connected (polling).
5+
* The server plugin cannot use `process.env.OPENCODE_CLIENT` to detect TUI
6+
* because the server runs in a separate process from the TUI client.
7+
*/
8+
9+
export interface RpcNotification {
10+
id: number;
11+
type: string;
12+
payload: Record<string, unknown>;
13+
sessionId?: string;
14+
}
15+
16+
let queue: RpcNotification[] = [];
17+
let nextNotificationId = 1;
18+
// Timestamp of last drain — used to detect if a TUI is actively polling.
19+
// The TUI polls every 500ms; we consider it connected if it polled within
20+
// the last 3 seconds (6× the poll interval, tolerates transient delays).
21+
//
22+
// PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on
23+
// session A plus an OpenCode Desktop opened on session B for the same project,
24+
// whose newer RPC server this TUI's port discovery then selects). The TUI
25+
// poller drains with ITS active session id, so a session is "TUI-connected"
26+
// only if a TUI recently drained FOR THAT session. A process-global timestamp
27+
// would make session B's producers (`/aft-status`, configure warnings, etc.)
28+
// take the TUI-dialog path because session A's TUI is polling — queuing a
29+
// B-scoped dialog action that A's poller correctly refuses to show, so B's
30+
// notice is lost (it also suppressed B's non-TUI fallback). Tracking drains per
31+
// session routes each producer to the right delivery path.
32+
const lastDrainAtBySession = new Map<string, number>();
33+
let lastDrainAtAny = 0;
34+
const TUI_CONNECTED_WINDOW_MS = 3_000;
35+
36+
/** Push a notification for the TUI to pick up via polling. */
37+
export function pushNotification(
38+
type: string,
39+
payload: Record<string, unknown>,
40+
sessionId?: string,
41+
): void {
42+
queue.push({ id: nextNotificationId++, type, payload, sessionId });
43+
// Cap queue size to prevent unbounded growth if a TUI is not draining.
44+
// Session-fair eviction: a naive `slice(-50)` drops the globally-oldest
45+
// items, so a noisy session could evict ANOTHER session's single unseen
46+
// notification. Instead, always retain each session's newest item, then
47+
// fill the rest of the budget with the newest overall — no session can
48+
// starve another's pending dialog out of the window.
49+
if (queue.length > 100) {
50+
const newestPerSession = new Map<string | undefined, number>();
51+
for (const notification of queue) {
52+
const previous = newestPerSession.get(notification.sessionId);
53+
if (previous === undefined || notification.id > previous) {
54+
newestPerSession.set(notification.sessionId, notification.id);
55+
}
56+
}
57+
const mustKeep = new Set(newestPerSession.values());
58+
const byNewest = [...queue].sort((a, b) => b.id - a.id);
59+
const kept: RpcNotification[] = [];
60+
for (const notification of byNewest) {
61+
if (kept.length < 50 || mustKeep.has(notification.id)) kept.push(notification);
62+
}
63+
queue = kept.sort((a, b) => a.id - b.id);
64+
}
65+
}
66+
67+
/** Return pending notifications after acking the client's last received id.
68+
* Updates lastDrainAt so isTuiConnected() reflects recent activity.
69+
*
70+
* Session scoping: when `sessionId` is provided, only notifications tagged for
71+
* that session (or session-less/global ones) are returned and pruned — a
72+
* notification tagged for a DIFFERENT session is never handed to this client
73+
* and is never pruned by this client's ack. This matters because the in-memory
74+
* queue is per-process but a TUI can end up draining a process that also serves
75+
* OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
76+
* newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
77+
* so a Desktop-session dialog action would otherwise surface in an unrelated
78+
* TUI session. Each client also tracks its own `lastReceivedId`, so a global
79+
* watermark prune would let session A's ack drop session B's still-unseen
80+
* notification — scoping the prune to the acking session prevents that too.
81+
*
82+
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
83+
* notification stays queued until a later call acks it via a higher
84+
* `lastReceivedId`, so a lost poll response re-delivers on the next poll. */
85+
export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
86+
const now = Date.now();
87+
lastDrainAtAny = now;
88+
if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now);
89+
const matchesClient = (notification: RpcNotification): boolean =>
90+
sessionId === undefined ||
91+
notification.sessionId === undefined ||
92+
notification.sessionId === sessionId;
93+
if (lastReceivedId > 0) {
94+
// Prune only notifications THIS client both owns (session-matched) and has
95+
// acked (id <= lastReceivedId). Other sessions' notifications survive.
96+
queue = queue.filter(
97+
(notification) => !(notification.id <= lastReceivedId && matchesClient(notification)),
98+
);
99+
}
100+
return queue.filter(
101+
(notification) => notification.id > lastReceivedId && matchesClient(notification),
102+
);
103+
}
104+
105+
/** Whether a TUI client is actively polling for notifications.
106+
* Returns true only if a TUI has drained within the last 3 seconds.
107+
*
108+
* Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT
109+
* SESSION — this is what producers (`/aft-status`, configure warnings, etc.)
110+
* must use to decide dialog-vs-message, so a TUI on a different session in the
111+
* same process does not misroute their delivery. Omit it only for legacy/global
112+
* callers that genuinely have no session context; they fall back to "any
113+
* session recently drained" (the pre-per-session behavior). */
114+
export function isTuiConnected(sessionId?: string): boolean {
115+
const now = Date.now();
116+
if (sessionId !== undefined) {
117+
const at = lastDrainAtBySession.get(sessionId) ?? 0;
118+
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
119+
}
120+
return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS;
121+
}

0 commit comments

Comments
 (0)