From c07e96cd10202bc82c9d2263405d552ebf626f5a Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 1 Sep 2026 23:56:49 +0200 Subject: [PATCH] Add transport diagnostics and fix notification controls --- apps/mobile/src/application-name.ts | 6 ++ .../state/connection-runtime-context.test.tsx | 49 +++++++++++ .../src/state/connection-runtime-context.tsx | 81 +++++++++++++++++-- .../connection-transport-coordinator.test.ts | 34 ++++++++ .../state/connection-transport-coordinator.ts | 45 +++++++---- docs/COMPATIBILITY.md | 27 +++++++ docs/NOTIFICATIONS.md | 14 ++-- docs/PUSH_AGENT_RUNBOOK.md | 9 ++- .../src/tui.test.ts | 41 ++++++++++ .../opencode-notification-plugin/src/tui.ts | 2 +- 10 files changed, 276 insertions(+), 32 deletions(-) create mode 100644 packages/opencode-notification-plugin/src/tui.test.ts 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",