@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.
- package/.opencode/lib/opencode-v2-adapter.js +34 -11
- package/.opencode/lib/runtime.js +1 -1
- package/.opencode/lib/v2-notification-bridge.js +105 -0
- package/README.md +10 -0
- package/package.json +15 -3
- package/rpc.d.ts +12 -0
- package/rpc.js +32 -0
- package/tui.d.ts +16 -0
- package/tui.js +105 -0
|
@@ -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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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();
|
package/.opencode/lib/runtime.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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();
|