@codemem/opencode-plugin 0.45.0-rc.1 → 0.45.0-rc.2

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.
@@ -4,6 +4,7 @@ import {
4
4
  createRuntimeLocation,
5
5
  } from "./host-contract.js";
6
6
  import { createCodememRuntime } from "./runtime.js";
7
+ import { registerV2NotificationBridge } from "./v2-notification-bridge.js";
7
8
 
8
9
  const DEFAULT_EVENT_TASK_TIMEOUT_MS = 250;
9
10
  const FAILED_TOOL_ERROR = Object.freeze({
@@ -419,6 +420,11 @@ const waitForRuntimeDisposal = (runtime, waitForTask, timeoutMs) =>
419
420
  timeoutMs,
420
421
  );
421
422
 
423
+ const disposeNotificationRegistration = async (registration, waitForTask, timeoutMs) => {
424
+ if (!registration) return;
425
+ await waitForRegistrationDisposal([registration], waitForTask, timeoutMs);
426
+ };
427
+
422
428
  const reportDiagnosticSafely = async (runtime, code) => {
423
429
  try {
424
430
  await runtime.reportDiagnostic?.(code);
@@ -619,19 +625,36 @@ export const createOpenCodeV2Adapter = ({
619
625
  waitForRuntimeDisposalTask = defaultWaitForEventTask,
620
626
  } = {}) => async (context) => {
621
627
  const location = context.location;
622
- // OpenCode 2.0.2 exposes neither app logging nor toast APIs, so V2 uses local diagnostics only.
623
- const runtime = await createRuntime({
624
- location: createRuntimeLocation({
625
- project: { ...location.project, root: location.project.canonical },
626
- directory: location.directory,
627
- worktree: location.project.directory,
628
- }),
629
- host: createRuntimeHost({ log: async () => {}, notify: null }),
630
- });
631
- if (!runtime) return undefined;
628
+ const notificationBridge = await registerV2NotificationBridge(context);
629
+ let runtime;
630
+ try {
631
+ runtime = await createRuntime({
632
+ location: createRuntimeLocation({
633
+ project: { ...location.project, root: location.project.canonical },
634
+ directory: location.directory,
635
+ worktree: location.project.directory,
636
+ }),
637
+ host: createRuntimeHost({ log: async () => {}, notify: notificationBridge.notify }),
638
+ });
639
+ } catch (error) {
640
+ await disposeNotificationRegistration(
641
+ notificationBridge.registration,
642
+ waitForRegistrationTask,
643
+ eventTaskTimeoutMs,
644
+ );
645
+ throw error;
646
+ }
647
+ if (!runtime) {
648
+ await disposeNotificationRegistration(
649
+ notificationBridge.registration,
650
+ waitForRegistrationTask,
651
+ eventTaskTimeoutMs,
652
+ );
653
+ return undefined;
654
+ }
632
655
 
633
656
  const abortController = new AbortController();
634
- const registrations = [];
657
+ const registrations = notificationBridge.registration ? [notificationBridge.registration] : [];
635
658
  const translator = createV2EventTranslator();
636
659
  const scheduleCapture = createCaptureScheduler();
637
660
  const scheduleContext = createContextScheduler();
@@ -24,7 +24,7 @@ import { V2_ADAPTER_DIAGNOSTICS } from "./host-contract.js";
24
24
 
25
25
  const TRUTHY_VALUES = ["1", "true", "yes"];
26
26
  const DISABLED_VALUES = ["0", "false", "off"];
27
- const PINNED_BACKEND_VERSION = "0.45.0-rc.1";
27
+ const PINNED_BACKEND_VERSION = "0.45.0-rc.2";
28
28
  const COMPAT_CHECK_DELAY_MS = 1500;
29
29
  const COMPAT_CHECK_CACHE_TTL_MS = 5 * 60 * 1000;
30
30
  const MAX_UPDATE_STATUS_BYTES = 16 * 1024;
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { CodememNotifications } from "../../rpc.js";
3
+
4
+ const DEFAULT_MAX_NOTICES = 32;
5
+ const DEFAULT_REGISTRATION_TIMEOUT_MS = 250;
6
+
7
+ const resolvePositiveInteger = (value, fallback) =>
8
+ Number.isSafeInteger(value) && value > 0 ? value : fallback;
9
+
10
+ const disposeLateRegistration = (registrationTask) => {
11
+ void registrationTask
12
+ .then((registration) => registration.dispose())
13
+ .catch(() => {
14
+ // Timed-out advisory registration cleanup is best-effort.
15
+ });
16
+ };
17
+
18
+ const registerWithinTimeout = async (context, handlers, timeoutMs) => {
19
+ const registrationTask = Promise.resolve().then(() =>
20
+ context.rpc.register(CodememNotifications, handlers)
21
+ );
22
+ let timeout;
23
+ const timeoutTask = new Promise((resolve) => {
24
+ timeout = setTimeout(() => resolve({ status: "timeout" }), timeoutMs);
25
+ timeout.unref?.();
26
+ });
27
+ const result = await Promise.race([
28
+ registrationTask.then(
29
+ (registration) => ({ status: "registered", registration }),
30
+ () => ({ status: "failed" })
31
+ ),
32
+ timeoutTask,
33
+ ]);
34
+ clearTimeout(timeout);
35
+ if (result.status === "timeout") disposeLateRegistration(registrationTask);
36
+ return result.status === "registered" ? result.registration : null;
37
+ };
38
+
39
+ export const createNotificationBacklog = ({
40
+ maxNotices = DEFAULT_MAX_NOTICES,
41
+ createID = randomUUID,
42
+ } = {}) => {
43
+ const notices = [];
44
+ const limit = Number.isSafeInteger(maxNotices) && maxNotices > 0 ? maxNotices : DEFAULT_MAX_NOTICES;
45
+ let buffering = true;
46
+
47
+ return {
48
+ push({ message, variant }) {
49
+ const notice = { id: createID(), message, variant };
50
+ if (!buffering) return notice;
51
+ notices.push(notice);
52
+ while (notices.length > limit) notices.shift();
53
+ return notice;
54
+ },
55
+ drain() {
56
+ buffering = false;
57
+ return notices.splice(0, notices.length);
58
+ },
59
+ };
60
+ };
61
+
62
+ export const registerV2NotificationBridge = async (context, options = {}) => {
63
+ if (typeof context?.rpc?.register !== "function") {
64
+ return { notify: null, registration: null };
65
+ }
66
+
67
+ const backlog = createNotificationBacklog(options);
68
+ const registrationTimeoutMs = resolvePositiveInteger(
69
+ options.registrationTimeoutMs,
70
+ DEFAULT_REGISTRATION_TIMEOUT_MS
71
+ );
72
+ const registration = await registerWithinTimeout(
73
+ context,
74
+ { drain: async () => ({ notices: backlog.drain() }) },
75
+ registrationTimeoutMs
76
+ );
77
+ if (!registration) {
78
+ return { notify: null, registration: null };
79
+ }
80
+
81
+ let active = true;
82
+ return {
83
+ notify: async (input) => {
84
+ if (!active) return;
85
+ const notice = backlog.push(input);
86
+ try {
87
+ void Promise.resolve(registration.events.emit("notice", notice)).catch(() => {
88
+ // Replay remains available when live publication fails.
89
+ });
90
+ } catch {
91
+ // Replay remains available when live publication throws synchronously.
92
+ }
93
+ },
94
+ registration: {
95
+ async dispose() {
96
+ active = false;
97
+ try {
98
+ await registration.dispose();
99
+ } catch {
100
+ // Notification cleanup must not fail the server plugin lifecycle.
101
+ }
102
+ },
103
+ },
104
+ };
105
+ };
package/README.md CHANGED
@@ -32,6 +32,10 @@ npm plugin plus a checkout-local `.opencode/plugins/` copy), the first
32
32
  registration wins and later copies skip their hooks with a warning in
33
33
  `~/.codemem/plugin.log`.
34
34
 
35
+ OpenCode 2 automatically loads the package's `./tui` companion. It displays
36
+ best-effort injection, compatibility, persistence, and update notices from the
37
+ server plugin without making notification delivery part of capture or recall.
38
+
35
39
  ## Troubleshooting and rollback
36
40
 
37
41
  - No automatic recall on OpenCode 2: recall requires a non-empty latest
@@ -39,6 +43,8 @@ registration wins and later copies skip their hooks with a warning in
39
43
  turn skips recall instead of guessing. Capture and the manual tools still work.
40
44
  - Duplicate registration warning: remove either the configured npm entry or the
41
45
  project-local copy so only one Codemem plugin loads.
46
+ - No notifications on OpenCode 2: restart the TUI after installing or upgrading
47
+ the plugin so it loads the matching `./tui` companion.
42
48
  - Stop the OpenCode 2 path: set `CODEMEM_PLUGIN_IGNORE=1` in the environment that
43
49
  launches OpenCode 2, or remove the plugin entry from that host's config.
44
50
  OpenCode 1 keeps working from the same package.
@@ -57,6 +63,10 @@ the hyphenated IDs. OpenCode 2.0.2 automatic recall runs through
57
63
  retries and tool continuations replay retained context. Missing or blank identity
58
64
  skips recall safely, and auxiliary hooks remain isolated.
59
65
 
66
+ The `./rpc` export defines the location-scoped notification contract. The
67
+ `./tui` export subscribes before draining the bounded startup backlog, suppresses
68
+ duplicate notice IDs, and renders matching-location notices as Codemem toasts.
69
+
60
70
  `CodememPlugin` remains the canonical named OpenCode 1 function export.
61
71
  `OpencodeMemPlugin` remains available as a deprecated, reference-identical alias
62
72
  for integrations created before the Codemem rename. No removal version is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/opencode-plugin",
3
- "version": "0.45.0-rc.1",
3
+ "version": "0.45.0-rc.2",
4
4
  "description": "CodeMem plugin for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -9,11 +9,23 @@
9
9
  ".": {
10
10
  "types": "./index.d.ts",
11
11
  "import": "./index.js"
12
+ },
13
+ "./rpc": {
14
+ "types": "./rpc.d.ts",
15
+ "import": "./rpc.js"
16
+ },
17
+ "./tui": {
18
+ "types": "./tui.d.ts",
19
+ "import": "./tui.js"
12
20
  }
13
21
  },
14
22
  "files": [
15
23
  "index.js",
16
24
  "index.d.ts",
25
+ "rpc.js",
26
+ "rpc.d.ts",
27
+ "tui.js",
28
+ "tui.d.ts",
17
29
  "src/opencode-v2-contract-fixture.ts",
18
30
  "v2-contract-fixture/",
19
31
  "README.md",
@@ -23,11 +35,11 @@
23
35
  ".opencode/lib/"
24
36
  ],
25
37
  "dependencies": {
26
- "@opencode-ai/plugin": "1.18.29"
38
+ "@opencode-ai/plugin": "1.18.29",
39
+ "@opencode/plugin": "2.0.2"
27
40
  },
28
41
  "devDependencies": {
29
42
  "@opencode/cli": "2.0.2",
30
- "@opencode/plugin": "2.0.2",
31
43
  "@types/node": "^24.12.4",
32
44
  "vitest": "^4.1.11"
33
45
  },
package/rpc.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { Rpc } from "@opencode/plugin/rpc";
2
+
3
+ export type CodememNotificationVariant = "info" | "success" | "warning" | "error";
4
+
5
+ export interface CodememNotification {
6
+ readonly id: string;
7
+ readonly message: string;
8
+ readonly variant: CodememNotificationVariant;
9
+ }
10
+
11
+ export declare const CODEMEM_NOTIFICATION_RPC_ID: "codemem.notifications";
12
+ export declare const CodememNotifications: Rpc.PortableDefinition;
package/rpc.js ADDED
@@ -0,0 +1,32 @@
1
+ export const CODEMEM_NOTIFICATION_RPC_ID = "codemem.notifications";
2
+
3
+ const noticeSchema = {
4
+ type: "object",
5
+ properties: {
6
+ id: { type: "string" },
7
+ message: { type: "string" },
8
+ variant: { enum: ["info", "success", "warning", "error"] },
9
+ },
10
+ required: ["id", "message", "variant"],
11
+ additionalProperties: false,
12
+ };
13
+
14
+ export const CodememNotifications = {
15
+ id: CODEMEM_NOTIFICATION_RPC_ID,
16
+ methods: {
17
+ drain: {
18
+ input: { type: "object", additionalProperties: false },
19
+ output: {
20
+ type: "object",
21
+ properties: {
22
+ notices: { type: "array", items: noticeSchema },
23
+ },
24
+ required: ["notices"],
25
+ additionalProperties: false,
26
+ },
27
+ },
28
+ },
29
+ events: {
30
+ notice: { schema: noticeSchema },
31
+ },
32
+ };
package/tui.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type CodememTuiSetup = (
2
+ context: unknown,
3
+ ) => Promise<(() => Promise<void> | void) | void> | (() => Promise<void> | void) | void;
4
+
5
+ export interface CodememTuiPlugin {
6
+ readonly id: string;
7
+ readonly setup: CodememTuiSetup;
8
+ }
9
+
10
+ export declare function createCodememTuiPlugin(options?: {
11
+ readonly maxSeenNotices?: number;
12
+ readonly replayTimeoutMs?: number;
13
+ }): CodememTuiPlugin;
14
+
15
+ declare const plugin: CodememTuiPlugin;
16
+ export default plugin;
package/tui.js ADDED
@@ -0,0 +1,105 @@
1
+ import { CodememNotifications } from "./rpc.js";
2
+
3
+ const DEFAULT_MAX_SEEN_NOTICES = 64;
4
+ const DEFAULT_REPLAY_TIMEOUT_MS = 250;
5
+ const TOAST_VARIANTS = new Set(["info", "success", "warning", "error"]);
6
+
7
+ const sameLocation = (left, right) =>
8
+ left?.directory === right?.directory &&
9
+ (left?.workspaceID ?? null) === (right?.workspaceID ?? null);
10
+
11
+ const normalizeNotice = (value) => {
12
+ if (!value || typeof value !== "object") return null;
13
+ const id = typeof value.id === "string" ? value.id : "";
14
+ const message = typeof value.message === "string" ? value.message : "";
15
+ const variant = TOAST_VARIANTS.has(value.variant) ? value.variant : "warning";
16
+ if (!id || !message) return null;
17
+ return { id, message, variant };
18
+ };
19
+
20
+ export const createCodememTuiPlugin = ({
21
+ maxSeenNotices = DEFAULT_MAX_SEEN_NOTICES,
22
+ replayTimeoutMs = DEFAULT_REPLAY_TIMEOUT_MS,
23
+ } = {}) =>
24
+ ({
25
+ id: "codemem.tui",
26
+ async setup(context) {
27
+ const seen = new Set();
28
+ let active = true;
29
+ const seenLimit =
30
+ Number.isSafeInteger(maxSeenNotices) && maxSeenNotices > 0
31
+ ? maxSeenNotices
32
+ : DEFAULT_MAX_SEEN_NOTICES;
33
+ const location = context.location ?? context.data?.location?.default?.();
34
+ const show = (input) => {
35
+ if (!active) return;
36
+ const notice = normalizeNotice(input);
37
+ if (!notice || seen.has(notice.id)) return;
38
+ seen.add(notice.id);
39
+ while (seen.size > seenLimit) seen.delete(seen.values().next().value);
40
+ try {
41
+ context.ui.toast.show({
42
+ title: "Codemem",
43
+ message: notice.message,
44
+ variant: notice.variant,
45
+ });
46
+ } catch {
47
+ // Notifications must not affect the TUI plugin lifecycle.
48
+ }
49
+ };
50
+
51
+ let notifications;
52
+ try {
53
+ notifications = context.client.rpc(CodememNotifications);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+
58
+ let unsubscribe = () => {};
59
+ try {
60
+ unsubscribe = notifications.events.on("notice", (event) => {
61
+ if (location && !sameLocation(event.location, location)) return;
62
+ show(event.data);
63
+ });
64
+ } catch {
65
+ // Replay can still deliver notices when live subscription is unavailable.
66
+ }
67
+
68
+ const replayAbort = new AbortController();
69
+ const replayLimit =
70
+ Number.isSafeInteger(replayTimeoutMs) && replayTimeoutMs > 0
71
+ ? replayTimeoutMs
72
+ : DEFAULT_REPLAY_TIMEOUT_MS;
73
+ let replayTimer;
74
+ const replayTimeout = new Promise((resolve) => {
75
+ replayTimer = setTimeout(() => {
76
+ replayAbort.abort();
77
+ resolve(null);
78
+ }, replayLimit);
79
+ replayTimer.unref?.();
80
+ });
81
+ const replayTask = Promise.resolve()
82
+ .then(() =>
83
+ notifications.drain({}, {
84
+ ...(location ? { location } : {}),
85
+ signal: replayAbort.signal,
86
+ }),
87
+ )
88
+ .catch(() => null);
89
+ const replay = await Promise.race([replayTask, replayTimeout]);
90
+ clearTimeout(replayTimer);
91
+ for (const notice of Array.isArray(replay?.notices) ? replay.notices : []) show(notice);
92
+
93
+ return () => {
94
+ active = false;
95
+ replayAbort.abort();
96
+ try {
97
+ unsubscribe();
98
+ } catch {
99
+ // Best-effort cleanup only.
100
+ }
101
+ };
102
+ },
103
+ });
104
+
105
+ export default createCodememTuiPlugin();