diff --git a/apps/mobile/src/application-name.ts b/apps/mobile/src/application-name.ts
index 3b8e0e0..1fd9804 100644
--- a/apps/mobile/src/application-name.ts
+++ b/apps/mobile/src/application-name.ts
@@ -1,3 +1,9 @@
import Constants from "expo-constants";
+import { Platform } from "react-native";
export const applicationName = Constants.expoConfig?.name ?? "OpenCode2 Mobile";
+export const applicationVersion = Constants.expoConfig?.version ?? "unknown";
+export const applicationBuild =
+ Platform.OS === "ios"
+ ? (Constants.expoConfig?.ios?.buildNumber ?? "unknown")
+ : String(Constants.expoConfig?.android?.versionCode ?? "unknown");
diff --git a/apps/mobile/src/state/connection-runtime-context.test.tsx b/apps/mobile/src/state/connection-runtime-context.test.tsx
index e680de3..e41495f 100644
--- a/apps/mobile/src/state/connection-runtime-context.test.tsx
+++ b/apps/mobile/src/state/connection-runtime-context.test.tsx
@@ -30,6 +30,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({
mockClientCalls.set(options.baseUrl, count + 1);
return count % 2 === 0 ? pair.rest : pair.event;
},
+ openCodeClientContractVersion: "test-contract",
}));
jest.mock("../connections/connections-context", () => ({
@@ -102,6 +103,10 @@ test("aborts and ignores the old generation when switching connections", async (
version: "test",
});
expect(screen.getByText("connected")).toBeOnTheScreen();
+ expect(screen.getByTestId("runtime-diagnostics").props.children).toContain(
+ "generation_starts=1\ndurable_sequence_gaps=0\nsnapshot_requests=1\nsnapshots_installed=1",
+ );
+ expect(screen.getByTestId("runtime-diagnostics").props.children).toContain("generation=startup");
mockSelectedProfileId = "connection-1";
view.rerender(
@@ -160,6 +165,49 @@ test("aggregates event bursts without evicting transport status history", () =>
expect(diagnostics).toHaveLength(5);
});
+test("bounds diagnostic kinds independently and formats redacted transport metadata", () => {
+ let diagnostics: RuntimeDiagnosticEntry[] = [
+ { atMs: 1, kind: "event", value: "session.updated" },
+ ];
+ for (let index = 0; index < 70; index += 1) {
+ diagnostics = appendDiagnostic(diagnostics, {
+ atMs: 300 + index,
+ kind: "event",
+ value: `event.${index}`,
+ });
+ diagnostics = appendDiagnostic(diagnostics, {
+ atMs: 100 + index,
+ kind: "status",
+ value: index % 2 === 0 ? "connecting" : "connected",
+ });
+ diagnostics = appendDiagnostic(diagnostics, {
+ atMs: 200 + index,
+ kind: "generation",
+ value: "durable_gap",
+ });
+ }
+
+ expect(diagnostics.filter((entry) => entry.kind === "event")).toHaveLength(64);
+ expect(diagnostics.filter((entry) => entry.kind === "status")).toHaveLength(64);
+ expect(diagnostics.filter((entry) => entry.kind === "generation")).toHaveLength(64);
+ expect(
+ formatDiagnostics("connected", diagnostics, {
+ appBuild: "7",
+ appVersion: "0.1.4",
+ clientContractVersion: "0.0.0-beta-18387",
+ metrics: {
+ durableSequenceGaps: 12,
+ generationStarts: 14,
+ snapshotRequests: 14,
+ snapshotsInstalled: 10,
+ },
+ serverVersion: "unsafe\nvalue",
+ }),
+ ).toContain(
+ "app_version=0.1.4\napp_build=7\nclient_contract=0.0.0-beta-18387\nserver_version=unknown\ngeneration_starts=14\ndurable_sequence_gaps=12\nsnapshot_requests=14\nsnapshots_installed=10",
+ );
+});
+
function RuntimeStatus() {
const runtime = useConnectionRuntime();
return (
@@ -168,6 +216,7 @@ function RuntimeStatus() {
{runtime.connectionId}:{runtime.restClient ? "ready" : "none"}
+ {runtime.getDiagnosticsText()}
>
);
}
diff --git a/apps/mobile/src/state/connection-runtime-context.tsx b/apps/mobile/src/state/connection-runtime-context.tsx
index 4b5f98d..0381d6a 100644
--- a/apps/mobile/src/state/connection-runtime-context.tsx
+++ b/apps/mobile/src/state/connection-runtime-context.tsx
@@ -3,6 +3,7 @@ import {
type LocationRef,
type OpenCodeClient,
type OpenCodeEvent,
+ openCodeClientContractVersion,
} from "@opencode2-mobile/opencode-adapter";
import { focusManager, onlineManager, useQueryClient } from "@tanstack/react-query";
import * as Network from "expo-network";
@@ -17,7 +18,7 @@ import {
} from "react";
import { AppState } from "react-native";
-import { applicationName } from "../application-name";
+import { applicationBuild, applicationName, applicationVersion } from "../application-name";
import { connectionAuthorizationHeader } from "../connections/connection-authorization";
import { useConnections } from "../connections/connections-context";
import { boundedOpenCodeFetch, expoOpenCodeFetch } from "../expo-open-code-fetch";
@@ -77,6 +78,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
}>();
const statusRef = useRef("idle");
const diagnosticsRef = useRef([]);
+ const transportMetricsRef = useRef(emptyTransportDiagnosticMetrics());
const selectedRevisionRef = useRef<{ id: string; updatedAtMs: number } | undefined>(undefined);
const selected = connections.profiles.find(
(profile) => profile.id === connections.selectedProfileId,
@@ -92,6 +94,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
selectedRevisionRef.current = undefined;
statusRef.current = "idle";
diagnosticsRef.current = [];
+ transportMetricsRef.current = emptyTransportDiagnosticMetrics();
setStatus("idle");
setReconnectAttempt(0);
setCacheMetadata(undefined);
@@ -115,6 +118,7 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
}
statusRef.current = "connecting";
diagnosticsRef.current = [];
+ transportMetricsRef.current = emptyTransportDiagnosticMetrics();
resetTranscriptPerformanceMetrics();
setStatus("connecting");
setReconnectAttempt(0);
@@ -166,7 +170,20 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
value: redactedEventType(event),
});
},
+ onDurableGap() {
+ transportMetricsRef.current.durableSequenceGaps += 1;
+ },
+ onGeneration(reason) {
+ transportMetricsRef.current.generationStarts += 1;
+ transportMetricsRef.current.snapshotRequests += 1;
+ diagnosticsRef.current = appendDiagnostic(diagnosticsRef.current, {
+ atMs: Date.now(),
+ kind: "generation",
+ value: reason,
+ });
+ },
onSnapshot(snapshot) {
+ transportMetricsRef.current.snapshotsInstalled += 1;
setServerVersion(snapshot.health.version);
queryClient.setQueryData(openCodeQueryKeys.health(selected.id), snapshot.health);
queryClient.setQueryData(openCodeQueryKeys.projects(selected.id), snapshot.projects);
@@ -255,7 +272,13 @@ export function ConnectionRuntimeProvider({ children }: { children: ReactNode })
eventLocations,
getDiagnosticsText: () =>
[
- formatDiagnostics(statusRef.current, diagnosticsRef.current),
+ formatDiagnostics(statusRef.current, diagnosticsRef.current, {
+ appBuild: applicationBuild,
+ appVersion: applicationVersion,
+ clientContractVersion: openCodeClientContractVersion,
+ metrics: transportMetricsRef.current,
+ ...(serverVersion ? { serverVersion } : {}),
+ }),
formatTranscriptPerformanceDiagnostics(getTranscriptPerformanceMetrics()),
].join("\n\n"),
includeAttentionLocation,
@@ -287,10 +310,27 @@ export type RuntimeDiagnosticEntry = {
atMs: number;
attempt?: number;
count?: number;
- kind: "event" | "status";
+ kind: "event" | "generation" | "status";
value: string;
};
+export type TransportDiagnosticMetrics = {
+ durableSequenceGaps: number;
+ generationStarts: number;
+ snapshotRequests: number;
+ snapshotsInstalled: number;
+};
+
+type RuntimeDiagnosticMetadata = {
+ appBuild: string;
+ appVersion: string;
+ clientContractVersion: string;
+ metrics: TransportDiagnosticMetrics;
+ serverVersion?: string;
+};
+
+const maxRuntimeDiagnosticsPerKind = 64;
+
export function appendDiagnostic(current: RuntimeDiagnosticEntry[], entry: RuntimeDiagnosticEntry) {
const next = [...current];
if (entry.kind === "event") {
@@ -313,9 +353,12 @@ export function appendDiagnostic(current: RuntimeDiagnosticEntry[], entry: Runti
next.push(entry);
}
- while (next.length > 64) {
- const oldestEventIndex = next.findIndex((currentEntry) => currentEntry.kind === "event");
- next.splice(oldestEventIndex >= 0 ? oldestEventIndex : 0, 1);
+ while (
+ next.filter((currentEntry) => currentEntry.kind === entry.kind).length >
+ maxRuntimeDiagnosticsPerKind
+ ) {
+ const oldestKindIndex = next.findIndex((currentEntry) => currentEntry.kind === entry.kind);
+ next.splice(oldestKindIndex, 1);
}
return next;
}
@@ -323,8 +366,21 @@ export function appendDiagnostic(current: RuntimeDiagnosticEntry[], entry: Runti
export function formatDiagnostics(
status: ConnectionTransportStatus,
diagnostics: RuntimeDiagnosticEntry[],
+ metadata?: RuntimeDiagnosticMetadata,
) {
const lines = [`${applicationName} redacted transport diagnostics`, `current_status=${status}`];
+ if (metadata) {
+ lines.push(
+ `app_version=${redactedDiagnosticValue(metadata.appVersion)}`,
+ `app_build=${redactedDiagnosticValue(metadata.appBuild)}`,
+ `client_contract=${redactedDiagnosticValue(metadata.clientContractVersion)}`,
+ `server_version=${redactedDiagnosticValue(metadata.serverVersion ?? "unknown")}`,
+ `generation_starts=${metadata.metrics.generationStarts}`,
+ `durable_sequence_gaps=${metadata.metrics.durableSequenceGaps}`,
+ `snapshot_requests=${metadata.metrics.snapshotRequests}`,
+ `snapshots_installed=${metadata.metrics.snapshotsInstalled}`,
+ );
+ }
for (const entry of diagnostics) {
lines.push(
`${formatDiagnosticTimestamp(entry.atMs)} ${entry.kind}=${entry.value}${
@@ -335,6 +391,19 @@ export function formatDiagnostics(
return lines.join("\n");
}
+function emptyTransportDiagnosticMetrics(): TransportDiagnosticMetrics {
+ return {
+ durableSequenceGaps: 0,
+ generationStarts: 0,
+ snapshotRequests: 0,
+ snapshotsInstalled: 0,
+ };
+}
+
+function redactedDiagnosticValue(value: string) {
+ return /^[a-zA-Z0-9][a-zA-Z0-9.+_-]{0,127}$/.test(value) ? value : "unknown";
+}
+
export function formatDiagnosticTimestamp(atMs: number) {
const date = new Date(atMs);
const offsetMinutes = -date.getTimezoneOffset();
diff --git a/apps/mobile/src/state/connection-transport-coordinator.test.ts b/apps/mobile/src/state/connection-transport-coordinator.test.ts
index 468d767..c226259 100644
--- a/apps/mobile/src/state/connection-transport-coordinator.test.ts
+++ b/apps/mobile/src/state/connection-transport-coordinator.test.ts
@@ -3,6 +3,7 @@ import { expect, jest, test } from "@jest/globals";
import type { OpenCodeClient, OpenCodeEvent } from "@opencode2-mobile/opencode-adapter";
import { eventRequiresConnectionSnapshot } from "./connection-event-query-bridge";
import {
+ type ConnectionGenerationReason,
ConnectionTransportCoordinator,
type ConnectionTransportCoordinatorOptions,
type ConnectionTransportStatus,
@@ -37,11 +38,15 @@ test("buffers events until authoritative snapshots are installed", async () => {
test("deduplicates event IDs and detects durable sequence gaps", async () => {
const stream = createEventStream();
const onEvent = jest.fn();
+ const onDurableGap = jest.fn();
const onSnapshot = jest.fn();
const onUncertain = jest.fn();
+ const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
+ onDurableGap,
onEvent,
+ onGeneration: (reason) => generationReasons.push(reason),
onSnapshot,
onUncertain,
restClient: createSnapshotClient(true).client,
@@ -57,16 +62,20 @@ test("deduplicates event IDs and detects durable sequence gaps", async () => {
await flush();
expect(onEvent).toHaveBeenCalledTimes(2);
+ expect(onDurableGap).toHaveBeenCalledTimes(1);
expect(onUncertain).toHaveBeenCalledTimes(1);
expect(onSnapshot).toHaveBeenCalledTimes(2);
+ expect(generationReasons).toEqual(["startup", "durable_gap"]);
});
test("reconnects with bounded full-jitter backoff", async () => {
const stream = createEventStream();
const scheduled: Array<{ callback: () => void; delay: number }> = [];
const statuses: ConnectionTransportStatus[] = [];
+ const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
+ onGeneration: (reason) => generationReasons.push(reason),
onStatus: (status) => statuses.push(status),
random: () => 0.5,
restClient: createSnapshotClient(true).client,
@@ -86,6 +95,7 @@ test("reconnects with bounded full-jitter backoff", async () => {
scheduled[0]?.callback();
expect(stream.generations).toBe(2);
+ expect(generationReasons).toEqual(["startup", "retry"]);
});
test("bounds the pre-snapshot event buffer and marks state uncertain", async () => {
@@ -167,8 +177,10 @@ test("reconciles coordinator-owned roots for an uncertain event type", async ()
const stream = createEventStream();
const onSnapshot = jest.fn();
const onUncertain = jest.fn();
+ const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
+ onGeneration: (reason) => generationReasons.push(reason),
onSnapshot,
onUncertain,
restClient: createSnapshotClient(true).client,
@@ -183,6 +195,7 @@ test("reconciles coordinator-owned roots for an uncertain event type", async ()
expect(onUncertain).toHaveBeenCalledTimes(1);
expect(onSnapshot).toHaveBeenCalledTimes(2);
+ expect(generationReasons).toEqual(["startup", "event_reconciliation"]);
});
test("keeps a healthy generation live for installation advisory events", async () => {
@@ -290,8 +303,10 @@ test("reconciles a replacement snapshot after the stream restarts", async () =>
test("stops streams while backgrounded or offline", async () => {
const stream = createEventStream();
const statuses: ConnectionTransportStatus[] = [];
+ const generationReasons: ConnectionGenerationReason[] = [];
const coordinator = createCoordinator({
eventClient: { event: { subscribe: stream.subscribe } } as never,
+ onGeneration: (reason) => generationReasons.push(reason),
onStatus: (status) => statuses.push(status),
restClient: createSnapshotClient(true).client,
});
@@ -310,6 +325,23 @@ test("stops streams while backgrounded or offline", async () => {
coordinator.setOnline(true);
expect(stream.generations).toBe(3);
+ expect(generationReasons).toEqual(["startup", "foreground", "network_restored"]);
+ coordinator.stop();
+});
+
+test("records explicit reconciliation as a generation reason", async () => {
+ const generationReasons: ConnectionGenerationReason[] = [];
+ const coordinator = createCoordinator({
+ eventClient: { event: { subscribe: createEventStream().subscribe } } as never,
+ onGeneration: (reason) => generationReasons.push(reason),
+ restClient: createSnapshotClient(true).client,
+ });
+
+ coordinator.start();
+ await flush();
+ coordinator.reconcile();
+
+ expect(generationReasons).toEqual(["startup", "manual_reconcile"]);
coordinator.stop();
});
@@ -317,7 +349,9 @@ function createCoordinator(overrides: Partial undefined),
+ ...(overrides.onGeneration ? { onGeneration: overrides.onGeneration } : {}),
onSnapshot: overrides.onSnapshot ?? (() => undefined),
onStatus: overrides.onStatus ?? (() => undefined),
onUncertain: overrides.onUncertain ?? (() => undefined),
diff --git a/apps/mobile/src/state/connection-transport-coordinator.ts b/apps/mobile/src/state/connection-transport-coordinator.ts
index 4b787d2..96c2f30 100644
--- a/apps/mobile/src/state/connection-transport-coordinator.ts
+++ b/apps/mobile/src/state/connection-transport-coordinator.ts
@@ -17,6 +17,15 @@ export type ConnectionTransportStatus =
| "stale"
| "unauthorized";
+export type ConnectionGenerationReason =
+ | "durable_gap"
+ | "event_reconciliation"
+ | "foreground"
+ | "manual_reconcile"
+ | "network_restored"
+ | "retry"
+ | "startup";
+
export type ConnectionSnapshot = {
activeSessions: Record;
health: ServiceHealth;
@@ -30,7 +39,9 @@ export type ConnectionTransportCoordinatorOptions = {
eventClient: EventClient;
maxBufferedEvents?: number;
maxSeenEventIds?: number;
+ onDurableGap?: () => void;
onEvent: (event: OpenCodeEvent) => void;
+ onGeneration?: (reason: ConnectionGenerationReason) => void;
onSnapshot: (snapshot: ConnectionSnapshot) => void;
onStatus: (status: ConnectionTransportStatus, reconnectAttempt: number) => void;
onUncertain: (event?: OpenCodeEvent) => void;
@@ -50,7 +61,7 @@ export class ConnectionTransportCoordinator {
controller: AbortController;
id: number;
snapshotTimeout: ReturnType | undefined;
- uncertain: boolean;
+ uncertainReason: ConnectionGenerationReason | undefined;
}
| undefined;
private readonly durableSequences = new Map();
@@ -70,7 +81,7 @@ export class ConnectionTransportCoordinator {
this.started = true;
if (!this.online) this.setStatus("offline");
else if (!this.foreground) this.setStatus("stale");
- else this.openGeneration();
+ else this.openGeneration("startup");
}
stop() {
@@ -90,7 +101,7 @@ export class ConnectionTransportCoordinator {
this.cancelGeneration();
this.setStatus(this.online ? "stale" : "offline");
} else if (this.online) {
- this.openGeneration();
+ this.openGeneration("foreground");
}
}
@@ -103,15 +114,15 @@ export class ConnectionTransportCoordinator {
this.cancelGeneration();
this.setStatus("offline");
} else if (this.foreground) {
- this.openGeneration();
+ this.openGeneration("network_restored");
}
}
reconcile() {
- if (this.started && this.online && this.foreground) this.openGeneration();
+ if (this.started && this.online && this.foreground) this.openGeneration("manual_reconcile");
}
- private openGeneration() {
+ private openGeneration(reason: ConnectionGenerationReason) {
this.clearRetry();
this.cancelGeneration();
const generation = ++this.generation;
@@ -123,8 +134,9 @@ export class ConnectionTransportCoordinator {
() => this.failGeneration(generation, new Error("SNAPSHOT_TIMEOUT")),
Math.max(1, this.options.snapshotTimeoutMs ?? 10_000),
),
- uncertain: false,
+ uncertainReason: undefined,
};
+ this.options.onGeneration?.(reason);
this.setStatus(this.reconnectAttempt > 0 ? "reconnecting" : "connecting");
const buffer: OpenCodeEvent[] = [];
@@ -153,8 +165,9 @@ export class ConnectionTransportCoordinator {
}
buffer.length = 0;
buffering = false;
- if (this.activeGeneration?.uncertain) {
- this.openGeneration();
+ const uncertainReason = this.activeGeneration?.uncertainReason;
+ if (uncertainReason) {
+ this.openGeneration(uncertainReason);
return;
}
this.reconnectAttempt = 0;
@@ -182,8 +195,9 @@ export class ConnectionTransportCoordinator {
buffer.push(event);
} else {
if (!this.applyEvent(event)) return;
- if (this.activeGeneration?.uncertain) {
- this.openGeneration();
+ const uncertainReason = this.activeGeneration?.uncertainReason;
+ if (uncertainReason) {
+ this.openGeneration(uncertainReason);
return;
}
}
@@ -214,7 +228,10 @@ export class ConnectionTransportCoordinator {
if ("durable" in event) {
const previous = this.durableSequences.get(event.durable.aggregateID);
if (previous !== undefined && event.durable.seq > previous + 1) {
- if (this.activeGeneration) this.activeGeneration.uncertain = true;
+ if (this.activeGeneration && !this.activeGeneration.uncertainReason) {
+ this.activeGeneration.uncertainReason = "durable_gap";
+ }
+ this.options.onDurableGap?.();
this.options.onUncertain(event);
}
if (previous !== undefined && event.durable.seq <= previous) return true;
@@ -222,7 +239,7 @@ export class ConnectionTransportCoordinator {
}
this.options.onEvent(event);
if (this.options.shouldReconcileEvent?.(event) && this.activeGeneration) {
- this.activeGeneration.uncertain = true;
+ this.activeGeneration.uncertainReason ??= "event_reconciliation";
this.options.onUncertain(event);
}
return true;
@@ -261,7 +278,7 @@ export class ConnectionTransportCoordinator {
const delay = Math.floor((this.options.random ?? Math.random)() * cap);
this.retryHandle = (this.options.schedule ?? defaultSchedule)(() => {
this.retryHandle = undefined;
- if (this.started && this.online && this.foreground) this.openGeneration();
+ if (this.started && this.online && this.foreground) this.openGeneration("retry");
}, delay);
}
diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md
index af88f9b..d7d754c 100644
--- a/docs/COMPATIBILITY.md
+++ b/docs/COMPATIBILITY.md
@@ -577,6 +577,33 @@ slot component.
The probe recorded no address, credential, token, pairing code, identifier,
prompt, path, form response, or server content.
+## 2026-08-31: OpenCode TUI notification controls probe
+
+### Stack
+
+- Host: Linux
+- OpenCode CLI and server: beta 18721
+- OpenCode notification plugin contract: beta 18387
+- Notification broker: local systemd user service
+
+### Results
+
+| Probe | Result |
+| --- | --- |
+| Load the server and TUI plugins from the compiled `dist` directory | Pass |
+| List status, pause, and enable controls in the command palette | Pass |
+| Invoke enable through authenticated loopback ingress and persist the broker state | Pass |
+| Register controls without opening the TUI sidebar | Pass after fix |
+
+The controls were previously registered from an empty `sidebar.footer` slot.
+OpenCode beta 18721 does not render that slot while the sidebar is closed, so the
+commands were absent. Registering from the always-mounted `app` slot keeps the
+keymap layer under the required TUI provider and makes the commands available on
+startup.
+
+The probe recorded no address, credential, token, identifier, prompt, path, or
+server content.
+
## 2026-08-26: physical iPhone command-event stability
### Stack
diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md
index 2a769d5..712cf4e 100644
--- a/docs/NOTIFICATIONS.md
+++ b/docs/NOTIFICATIONS.md
@@ -85,7 +85,7 @@ OpenCode.
{
"plugins": [
{
- "package": "/absolute/path/opencode2-mobile/packages/opencode-notification-plugin/dist/index.js",
+ "package": "/absolute/path/opencode2-mobile/packages/opencode-notification-plugin/dist",
"options": {
"brokerOrigin": "http://127.0.0.1:37101",
"tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token"
@@ -106,14 +106,14 @@ plugin cannot send retroactive notifications for requests created before plugin
installation, and a server crash before an event reaches plugin storage can lose
that notification. The app still reconciles authoritative state after every tap.
-For controls in a locally installed OpenCode TUI, add the compiled TUI entry to
+For controls in a locally installed OpenCode TUI, add the compiled plugin directory to
`~/.config/opencode/cli.json` with the same options:
```json
{
"plugins": [
{
- "package": "/absolute/path/opencode2-mobile/packages/opencode-notification-plugin/dist/tui.js",
+ "package": "/absolute/path/opencode2-mobile/packages/opencode-notification-plugin/dist",
"options": {
"brokerOrigin": "http://127.0.0.1:37101",
"tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token"
@@ -123,10 +123,10 @@ For controls in a locally installed OpenCode TUI, add the compiled TUI entry to
}
```
-The local source package directory is not a valid server plugin entry in the
-tested OpenCode beta. Configure the two compiled files explicitly, rebuild after
-changes, restart the OpenCode service for server-plugin changes, and reopen the
-TUI for TUI-plugin changes.
+The local source package directory has no root `index.js` and is not a valid
+plugin entry. Configure the compiled `dist` directory, which contains both
+`index.js` and `tui.js`. Rebuild after changes, restart the OpenCode service for
+server-plugin changes, and reopen the TUI for TUI-plugin changes.
## Start and pair
diff --git a/docs/PUSH_AGENT_RUNBOOK.md b/docs/PUSH_AGENT_RUNBOOK.md
index 77d23a6..1222e42 100644
--- a/docs/PUSH_AGENT_RUNBOOK.md
+++ b/docs/PUSH_AGENT_RUNBOOK.md
@@ -225,7 +225,7 @@ Merge one entry into the existing `plugins` array:
"$schema": "https://opencode.ai/config.json",
"plugins": [
{
- "package": "/home/user/.local/share/opencode2-mobile/packages/opencode-notification-plugin/dist/index.js",
+ "package": "/home/user/.local/share/opencode2-mobile/packages/opencode-notification-plugin/dist",
"options": {
"brokerOrigin": "http://127.0.0.1:37101",
"tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token"
@@ -244,7 +244,7 @@ Configure the TUI controls separately in `~/.config/opencode/cli.json`:
{
"plugins": [
{
- "package": "/home/user/.local/share/opencode2-mobile/packages/opencode-notification-plugin/dist/tui.js",
+ "package": "/home/user/.local/share/opencode2-mobile/packages/opencode-notification-plugin/dist",
"options": {
"brokerOrigin": "http://127.0.0.1:37101",
"tokenFile": "/home/user/.local/state/opencode-mobile-notifications/plugin.token"
@@ -254,8 +254,9 @@ Configure the TUI controls separately in `~/.config/opencode/cli.json`:
}
```
-The tested OpenCode beta does not resolve the local source package directory as
-a server plugin. Use the two compiled files shown above.
+The local source package directory has no root `index.js`. Configure the compiled
+`dist` directory shown above; direct `.js` paths are rejected by current OpenCode
+betas.
OpenCode and the broker must share a network namespace. If OpenCode is in a
container, co-locate the broker there. Do not weaken the plugin's loopback check
diff --git a/packages/opencode-notification-plugin/src/tui.test.ts b/packages/opencode-notification-plugin/src/tui.test.ts
new file mode 100644
index 0000000..a35ee91
--- /dev/null
+++ b/packages/opencode-notification-plugin/src/tui.test.ts
@@ -0,0 +1,41 @@
+import { beforeEach, expect, it, vi } from "vitest";
+
+const { readBrokerAccess } = vi.hoisted(() => ({ readBrokerAccess: vi.fn() }));
+
+vi.mock("@opencode-ai/plugin/tui", () => ({
+ Plugin: { define: (plugin: T) => plugin },
+}));
+vi.mock("./broker.js", () => ({
+ readBrokerAccess,
+ requestNotificationDeliveryState: vi.fn(),
+}));
+
+import plugin from "./tui.js";
+
+beforeEach(() => {
+ readBrokerAccess.mockResolvedValue({
+ brokerOrigin: "http://127.0.0.1:37101",
+ ingestToken: "test-token",
+ });
+});
+
+it("registers notification commands in the always-mounted app slot", async () => {
+ const layer = vi.fn();
+ const slot = vi.fn((claim) => claim);
+
+ await plugin.setup({
+ keymap: { layer },
+ options: {},
+ ui: { slot, toast: { show: vi.fn() } },
+ } as never);
+
+ expect(slot).toHaveBeenCalledWith(expect.objectContaining({ append: "app" }));
+ slot.mock.calls[0]?.[0].render({});
+ expect(layer).toHaveBeenCalledOnce();
+ const commands = layer.mock.calls[0]?.[0]().commands;
+ expect(commands?.map((command: { id: string }) => command.id)).toEqual([
+ "opencode-mobile-notifications.status",
+ "opencode-mobile-notifications.pause",
+ "opencode-mobile-notifications.enable",
+ ]);
+});
diff --git a/packages/opencode-notification-plugin/src/tui.ts b/packages/opencode-notification-plugin/src/tui.ts
index 0c63919..d439b87 100644
--- a/packages/opencode-notification-plugin/src/tui.ts
+++ b/packages/opencode-notification-plugin/src/tui.ts
@@ -21,7 +21,7 @@ export default Plugin.define({
}
};
return context.ui.slot({
- append: "sidebar.footer",
+ append: "app",
render() {
context.keymap.layer(() => ({
mode: "global",