@codemem/opencode-plugin 0.44.2 → 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.
@@ -0,0 +1,202 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const MAX_BINDINGS = 128;
4
+ const MAX_BRIEF_CHARS = 64_000;
5
+ const BINDING_TTL_MS = 10 * 60_000;
6
+ const LOOKUP_TIMEOUT_MS = 200;
7
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
8
+ const validId = (value) => typeof value === "string" && value.length > 0 && value.length <= 256;
9
+ const normalizedPromptText = (value) => typeof value === "string" ? value.trim() : null;
10
+
11
+ // Only this V1 adapter consumes the verified task metadata contract. A session's
12
+ // parent alone says nothing about who authored a particular user-role message.
13
+ // SDK-only callers must supply a complete message snapshot. The V1 adapter also
14
+ // requires the chat hook because its SDK can expose partially persisted parts.
15
+ export const createDelegationContext = ({
16
+ readSession, readMessage, now = Date.now, requirePromptSnapshot = false,
17
+ }) => {
18
+ const bindings = new Map();
19
+ const activeLookups = new Map();
20
+ const initializedAt = now();
21
+ let disposed = false;
22
+ const prune = () => {
23
+ for (const [key, binding] of bindings) {
24
+ if (now() - binding.observedAt > BINDING_TTL_MS) bindings.delete(key);
25
+ }
26
+ };
27
+ const observe = (event) => {
28
+ if (disposed) return;
29
+ prune();
30
+ if (event?.type === "session.deleted") {
31
+ const id = event.properties?.info?.id;
32
+ for (const binding of bindings.values()) {
33
+ if (binding.child === id || binding.parent === id) binding.closed = true;
34
+ }
35
+ return;
36
+ }
37
+ const part = event?.properties?.part;
38
+ if (event?.type !== "message.part.updated" || part?.type !== "tool" || part.tool !== "task") return;
39
+ const state = part.state;
40
+ const metadata = state?.metadata;
41
+ const input = state?.input;
42
+ const brief = normalizedPromptText(input?.prompt);
43
+ if (!validId(part.callID) || !validId(part.sessionID)) return;
44
+ const key = JSON.stringify([part.sessionID, part.callID]);
45
+ const previous = bindings.get(key);
46
+ if (["completed", "error"].includes(state?.status)) {
47
+ if (previous) previous.closed = true;
48
+ else if (bindings.size < MAX_BINDINGS) bindings.set(key, { observedAt: now(), closed: true });
49
+ return;
50
+ }
51
+ if (state?.status !== "running") return;
52
+ if (!validId(metadata?.sessionId) || metadata.parentSessionId !== part.sessionID
53
+ || metadata.sessionId === part.sessionID || !validId(input?.subagent_type)
54
+ || !brief || input.prompt.length > MAX_BRIEF_CHARS
55
+ || typeof input.description !== "string" || !Number.isFinite(state.time?.start)) {
56
+ if (previous) previous.ambiguous = true;
57
+ return;
58
+ }
59
+ const fingerprint = digest(JSON.stringify([metadata.sessionId, input.subagent_type, brief, state.time.start]));
60
+ if (previous) {
61
+ // A mutated/reused call is ambiguous; never reset its consumed message.
62
+ if (previous.fingerprint !== fingerprint) previous.ambiguous = true;
63
+ return;
64
+ }
65
+ // Do not evict a consumed call and then accept its replay as a fresh binding.
66
+ if (bindings.size >= MAX_BINDINGS || state.time.start < initializedAt
67
+ || now() - state.time.start > BINDING_TTL_MS) return;
68
+ bindings.set(key, {
69
+ fingerprint, parent: part.sessionID, child: metadata.sessionId, call: part.callID,
70
+ agent: input.subagent_type, brief, startedAt: state.time.start,
71
+ observedAt: now(), messageID: null, ambiguous: false,
72
+ });
73
+ };
74
+ const matchSnapshot = (snapshot, { sessionID, messageID, text }, candidates) => {
75
+ if (!snapshot || disposed) return null;
76
+ const [session, message] = snapshot;
77
+ const info = message?.info;
78
+ const parts = message?.parts;
79
+ if (session?.id !== sessionID || info?.id !== messageID || info.sessionID !== sessionID
80
+ || info.role !== "user" || !validId(info.agent) || !Number.isFinite(info.time?.created)
81
+ || !Array.isArray(parts) || parts.length !== 1) return null;
82
+ const part = parts[0];
83
+ const partText = normalizedPromptText(part?.text);
84
+ if (part?.type !== "text" || part.synthetic || part.ignored || typeof part.text !== "string"
85
+ || part.sessionID !== sessionID || part.messageID !== messageID || partText !== text) return null;
86
+ prune();
87
+ const reservedCandidates = new Set(candidates);
88
+ const hasNewMatch = [...bindings.values()].some((binding) => !reservedCandidates.has(binding)
89
+ && !binding.closed && binding.child === sessionID && binding.parent === session.parentID
90
+ && binding.agent === info.agent && binding.brief === partText
91
+ && info.time.created >= binding.startedAt);
92
+ if (hasNewMatch) return null;
93
+ const matches = candidates.filter((binding) => binding.child === sessionID
94
+ && binding.parent === session.parentID
95
+ && binding.agent === info.agent && binding.brief === partText
96
+ && info.time.created >= binding.startedAt);
97
+ if (matches.length !== 1) return null;
98
+ const binding = matches[0];
99
+ if (binding.ambiguous || (binding.messageID && binding.messageID !== messageID)) return null;
100
+ if (requirePromptSnapshot) {
101
+ const draft = binding.prompt;
102
+ const draftPart = draft?.parts?.[0];
103
+ if (draft?.message?.id !== messageID || draft.message.sessionID !== sessionID
104
+ || draft.message.role !== info.role || draft.message.agent !== info.agent
105
+ || draft.message.time?.created !== info.time.created || draft.parts?.length !== 1
106
+ || draftPart?.id !== part.id || draftPart?.sessionID !== sessionID
107
+ || draftPart?.messageID !== messageID || draftPart?.type !== "text"
108
+ || draftPart.synthetic || draftPart.ignored
109
+ || normalizedPromptText(draftPart.text) !== text) return null;
110
+ }
111
+ binding.messageID = messageID;
112
+ return Object.freeze({
113
+ version: 1, host: "opencode-v1", origin: "delegated_brief",
114
+ parent_session_id: binding.parent, child_session_id: sessionID,
115
+ task_call_id: binding.call, message_id: messageID,
116
+ requested_agent: binding.agent, current_agent: info.agent,
117
+ brief_sha256: digest(text),
118
+ });
119
+ };
120
+ // V1 invokes this with all resolved parts before saving them individually.
121
+ // Keep the message/parts references until lookup so later hooks' in-place
122
+ // changes are checked too. The host persists these original objects, even if
123
+ // a hook replaces output.message/parts. Do not fetch inside this hook.
124
+ const observePrompt = (input, output) => {
125
+ if (disposed) return;
126
+ prune();
127
+ const info = output?.message;
128
+ if (!validId(info?.id) || info.sessionID !== input?.sessionID || info.role !== "user"
129
+ || (input.messageID && input.messageID !== info.id)
130
+ || (input.agent && input.agent !== info.agent)
131
+ || !Number.isFinite(info.time?.created) || !validId(info.agent)) return;
132
+ for (const binding of bindings.values()) {
133
+ if (binding.closed || binding.child !== info.sessionID || binding.agent !== info.agent
134
+ || info.time.created < binding.startedAt) continue;
135
+ if (binding.messageID) continue;
136
+ const part = output.parts?.[0];
137
+ const promptMatches = output.parts?.length === 1 && part?.type === "text"
138
+ && !part.synthetic && !part.ignored
139
+ && normalizedPromptText(part.text) === binding.brief;
140
+ if (!requirePromptSnapshot || promptMatches) binding.messageID = info.id;
141
+ if (promptMatches) {
142
+ binding.prompt = { message: info, parts: output.parts };
143
+ }
144
+ }
145
+ };
146
+ const boundChildren = (parentSessionID) => {
147
+ if (disposed || !validId(parentSessionID)) return [];
148
+ prune();
149
+ return [...new Set([...bindings.values()]
150
+ .filter((binding) => !binding.closed && binding.parent === parentSessionID)
151
+ .map((binding) => binding.child))];
152
+ };
153
+ const boundChild = (parentSessionID, callID) => {
154
+ if (disposed || !validId(parentSessionID) || !validId(callID)) return null;
155
+ prune();
156
+ const binding = bindings.get(JSON.stringify([parentSessionID, callID]));
157
+ if (binding?.closed || binding?.parent !== parentSessionID || !validId(binding?.child)) return null;
158
+ return binding.child;
159
+ };
160
+ const resolve = (prompt) => {
161
+ const { sessionID, messageID } = prompt;
162
+ const text = normalizedPromptText(prompt.text);
163
+ if (disposed || !validId(sessionID) || !validId(messageID) || !text) return Promise.resolve(null);
164
+ prune();
165
+ // Reserve candidates now; later closure or TTL pruning must not invalidate
166
+ // snapshot work admitted while the binding was current.
167
+ const candidates = [...bindings.values()].filter((binding) => binding.child === sessionID && !binding.closed
168
+ && (!requirePromptSnapshot || (binding.messageID === messageID && binding.prompt)));
169
+ if (!readSession || !readMessage
170
+ || !candidates.some((binding) => !binding.messageID || binding.messageID === messageID)) return Promise.resolve(null);
171
+ const key = JSON.stringify([sessionID, messageID]);
172
+ // Duplicate requests share a lookup; distinct requests cannot outgrow the binding cache.
173
+ const existing = activeLookups.get(key);
174
+ if (existing) return existing.text === text ? existing.promise : Promise.resolve(null);
175
+ if (activeLookups.size >= bindings.size) return Promise.resolve(null);
176
+ const controller = new AbortController();
177
+ const completion = Promise.withResolvers();
178
+ let finished = false;
179
+ const finish = (context) => {
180
+ if (finished) return;
181
+ finished = true;
182
+ clearTimeout(timer);
183
+ activeLookups.delete(key);
184
+ controller.abort();
185
+ completion.resolve(context);
186
+ };
187
+ const timer = setTimeout(() => finish(null), LOOKUP_TIMEOUT_MS);
188
+ activeLookups.set(key, { text, promise: completion.promise, cancel: () => finish(null) });
189
+ void Promise.all([
190
+ Promise.resolve().then(() => controller.signal.aborted ? null : readSession(sessionID, controller.signal)),
191
+ Promise.resolve().then(() => controller.signal.aborted ? null : readMessage(sessionID, messageID, controller.signal)),
192
+ ]).then((snapshot) => {
193
+ if (!finished) finish(matchSnapshot(snapshot, { ...prompt, text }, candidates));
194
+ }).catch(() => finish(null));
195
+ return completion.promise;
196
+ };
197
+ return { boundChild, boundChildren, observe, observePrompt, resolve, dispose: () => {
198
+ disposed = true;
199
+ for (const lookup of activeLookups.values()) lookup.cancel();
200
+ bindings.clear();
201
+ } };
202
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @typedef {object} CodememLocation
3
+ * @property {unknown} project
4
+ * @property {string | undefined} directory
5
+ * @property {string | undefined} worktree
6
+ */
7
+
8
+ export const V2_ADAPTER_DIAGNOSTICS = Object.freeze({
9
+ contextCleanupTimeout: "v2_context_cleanup_timeout",
10
+ contextRecallFailed: "v2_context_recall_failed",
11
+ eventCaptureFailed: "v2_event_capture_failed",
12
+ eventStreamCleanupTimeout: "v2_event_stream_cleanup_timeout",
13
+ eventStreamEndedUnexpectedly: "v2_event_stream_ended_unexpectedly",
14
+ eventStreamFailed: "v2_event_stream_failed",
15
+ registrationCleanupTimeout: "v2_registration_cleanup_timeout",
16
+ runtimeCleanupTimeout: "v2_runtime_cleanup_timeout",
17
+ toolCaptureFailed: "v2_tool_capture_failed",
18
+ });
19
+
20
+ /**
21
+ * @typedef {object} CodememHost
22
+ * @property {(entry: {service: string, level: string, message: string, extra: object}) => Promise<unknown>} log
23
+ * @property {((notice: {message: string, variant: string}) => Promise<unknown>) | null} notify
24
+ * @property {((prompt: {sessionID: string, messageID: string, text: string}) => Promise<object | null>) | null} resolveCaptureContext
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} CodememCapturedEvent
29
+ * Event types use the OpenCode 1 vocabulary as the runtime's canonical wire names.
30
+ * Adapters retain the host-native event in `raw` for bounded diagnostic logging.
31
+ * @property {string} type
32
+ * @property {string | null} sessionID
33
+ * @property {unknown} messageInfo
34
+ * @property {unknown} part
35
+ * @property {unknown} usage
36
+ * @property {unknown} raw
37
+ */
38
+
39
+ /**
40
+ * @typedef {object} CodememPromptContext
41
+ * @property {string | null} sessionID
42
+ */
43
+
44
+ /**
45
+ * @typedef {object} CodememMessageTransformOptions
46
+ * @property {boolean} [deferDeliveryConfirmation] Let the adapter confirm whether host mutation succeeded.
47
+ * @property {boolean} [enableSystemSurface] Route V2 context through the message cache before adapting it to system parts.
48
+ * @property {boolean} [pruneAbsentCacheEntries] Remove replay entries absent from this transform's history.
49
+ * @property {boolean} [requireLatestUserMessageID] Skip recall unless the latest user message has a host ID.
50
+ */
51
+
52
+ /**
53
+ * @typedef {object} CodememMessageTransformResult
54
+ * @property {boolean} applied
55
+ * @property {"message" | "system"} surface Adapters must route injected output to this surface.
56
+ * @property {((status?: "handed_off" | "failed") => void) | undefined} [completeDelivery]
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} CodememToolResult
61
+ * @property {string | null} [id] Stable host tool-call identity; OpenCode V1 omits it.
62
+ * @property {string | null} sessionID
63
+ * @property {string} tool
64
+ * @property {object} args
65
+ */
66
+
67
+ /**
68
+ * @typedef {object} CodememRuntime
69
+ * @property {() => void} deactivate
70
+ * @property {() => Promise<void>} dispose
71
+ * @property {(context: CodememPromptContext) => Promise<void>} handleCompacting
72
+ * @property {(input: CodememPromptContext, output: object, options?: CodememMessageTransformOptions) => Promise<CodememMessageTransformResult>} transformMessages
73
+ * @property {(input: CodememPromptContext, output: object) => Promise<void>} transformSystem
74
+ * @property {(event: CodememCapturedEvent) => Promise<void>} handleEvent
75
+ * @property {(input: CodememToolResult, output: object) => Promise<void>} handleToolResult
76
+ * @property {(code: string) => Promise<void>} reportDiagnostic
77
+ * @property {Record<string, {description: string, args: object, execute: Function}>} tools
78
+ */
79
+
80
+ export const createRuntimeLocation = ({ project, directory, worktree }) => ({
81
+ project,
82
+ directory,
83
+ worktree,
84
+ });
85
+
86
+ export const createRuntimeHost = ({ log, notify = null, resolveCaptureContext = null }) => ({
87
+ log,
88
+ notify,
89
+ resolveCaptureContext,
90
+ });