@snarkipus/opencode-beads 0.9.1 → 0.9.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/README.md +6 -6
- package/package.json +3 -3
- package/src/opencode-runtime.ts +156 -0
- package/src/plugin-core.ts +92 -35
- package/src/plugin-hooks.ts +56 -0
- package/src/plugin.ts +5 -143
- package/src/prime.ts +71 -7
- package/vendor/manifest.json +2 -2
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Add the version-pinned plugin to your OpenCode config (`~/.config/opencode/openc
|
|
|
20
20
|
|
|
21
21
|
```json
|
|
22
22
|
{
|
|
23
|
-
"plugin": ["@snarkipus/opencode-beads@0.9.
|
|
23
|
+
"plugin": ["@snarkipus/opencode-beads@0.9.2"]
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
@@ -37,13 +37,13 @@ Skip `git init` when the project is already a Git worktree. `bd init` performs t
|
|
|
37
37
|
|
|
38
38
|
### Upgrading from 0.8.0
|
|
39
39
|
|
|
40
|
-
Version 0.8.0 installed a fork-managed skill under `.opencode/skills/beads`. Remove that old managed skill with the version-pinned 0.8.0 CLI before changing the plugin pin to 0.9.
|
|
40
|
+
Version 0.8.0 installed a fork-managed skill under `.opencode/skills/beads`. Remove that old managed skill with the version-pinned 0.8.0 CLI before changing the plugin pin to 0.9.2:
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
43
|
bunx @snarkipus/opencode-beads@0.8.0 remove
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
If the old skill was installed globally, append `--global` to that exact command. Do not substitute 0.9.
|
|
46
|
+
If the old skill was installed globally, append `--global` to that exact command. Do not substitute 0.9.2 in the removal command: the companion CLI no longer exists in 0.9.2.
|
|
47
47
|
|
|
48
48
|
For a repository that has not been initialized with Beads, run `bd init`; it creates the canonical shared skill as part of normal initialization. For an already-initialized repository that is missing the skill, upstream currently has no skill-only setup command. Do not run `bd setup codex` as a substitute because it also installs Codex hooks and generated instructions. Instead, initialize a temporary Git repository with the same `bd` version and copy only its canonical skill directory into the target repository:
|
|
49
49
|
|
|
@@ -92,11 +92,11 @@ The compatibility ranges and validated baselines for this release are:
|
|
|
92
92
|
|
|
93
93
|
| Component | Expected range | Validated baseline |
|
|
94
94
|
| --- | --- | --- |
|
|
95
|
-
| OpenCode | 1.18.3 through 1.x | 1.18.
|
|
96
|
-
| `bd` CLI | 1.0.5 through 1.x | 1.1.
|
|
95
|
+
| OpenCode | 1.18.3 through 1.x | 1.18.15 |
|
|
96
|
+
| `bd` CLI | 1.0.5 through 1.x | 1.1.2 |
|
|
97
97
|
| Bun | >=1.3.14 | 1.3.14 |
|
|
98
98
|
|
|
99
|
-
The OpenCode adapter builds against the exact paired `@opencode-ai/plugin` and `@opencode-ai/sdk` `1.18.
|
|
99
|
+
The OpenCode adapter builds against the exact paired `@opencode-ai/plugin` and `@opencode-ai/sdk` `1.18.15` releases and declares compatible optional peers from `1.18.3` through the stable `1.x` line. Both imports are type-only; the package does not install a second OpenCode runtime. Command and agent provenance is currently synced from Beads v1.1.2. Newer compatible releases may work but are not guaranteed until validated; when diagnosing a regression, reproduce it with the baselines above.
|
|
100
100
|
|
|
101
101
|
## Vendored Content
|
|
102
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snarkipus/opencode-beads",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A maintained OpenCode integration for the Beads issue tracker, including context injection, vendored workflows, and a task agent.",
|
|
6
6
|
"author": "Matt Jackson",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@opencode-ai/plugin": "1.18.
|
|
69
|
-
"@opencode-ai/sdk": "1.18.
|
|
68
|
+
"@opencode-ai/plugin": "1.18.15",
|
|
69
|
+
"@opencode-ai/sdk": "1.18.15",
|
|
70
70
|
"@types/bun": "1.3.4",
|
|
71
71
|
"@types/node": "^22.0.0",
|
|
72
72
|
"typescript": "5.9.3"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import type { Agent, SessionMessagesResponse } from "@opencode-ai/sdk";
|
|
3
|
+
import type { PluginRuntime } from "./plugin-core";
|
|
4
|
+
import { runBdPrime } from "./prime";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS = 5_000;
|
|
7
|
+
export const DEFAULT_OPENCODE_DIAGNOSTIC_TIMEOUT_MS = 1_000;
|
|
8
|
+
|
|
9
|
+
export class OpenCodeTimeoutError extends Error {
|
|
10
|
+
constructor(readonly operation: string, readonly timeoutMs: number) {
|
|
11
|
+
super(`OpenCode ${operation} timed out after ${timeoutMs}ms`);
|
|
12
|
+
this.name = "OpenCodeTimeoutError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OpenCodeRuntimeOptions {
|
|
17
|
+
requestTimeoutMs?: number;
|
|
18
|
+
diagnosticTimeoutMs?: number;
|
|
19
|
+
prime?: PluginRuntime["prime"];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withDeadline<T>(
|
|
23
|
+
operation: string,
|
|
24
|
+
timeoutMs: number,
|
|
25
|
+
execute: (signal: AbortSignal) => Promise<T>
|
|
26
|
+
): Promise<T> {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
let rejectTimeout: (error: OpenCodeTimeoutError) => void = () => {};
|
|
29
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
30
|
+
rejectTimeout = reject;
|
|
31
|
+
});
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
const error = new OpenCodeTimeoutError(operation, timeoutMs);
|
|
34
|
+
controller.abort(error);
|
|
35
|
+
rejectTimeout(error);
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return await Promise.race([execute(controller.signal), timeout]);
|
|
40
|
+
} finally {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return typeof value === "object" && value !== null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSessionMessage(value: unknown): boolean {
|
|
50
|
+
if (
|
|
51
|
+
!isRecord(value) ||
|
|
52
|
+
!isRecord(value.info) ||
|
|
53
|
+
(value.info.role !== "user" && value.info.role !== "assistant") ||
|
|
54
|
+
(value.info.agent !== undefined && typeof value.info.agent !== "string") ||
|
|
55
|
+
(value.info.system !== undefined && typeof value.info.system !== "string") ||
|
|
56
|
+
(value.info.model !== undefined &&
|
|
57
|
+
(!isRecord(value.info.model) ||
|
|
58
|
+
typeof value.info.model.providerID !== "string" ||
|
|
59
|
+
typeof value.info.model.modelID !== "string"))
|
|
60
|
+
) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (!Array.isArray(value.parts)) return false;
|
|
64
|
+
|
|
65
|
+
return value.parts.every(
|
|
66
|
+
(part) =>
|
|
67
|
+
isRecord(part) &&
|
|
68
|
+
typeof part.type === "string" &&
|
|
69
|
+
(part.text === undefined || typeof part.text === "string") &&
|
|
70
|
+
(part.synthetic === undefined || typeof part.synthetic === "boolean")
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isAgent(value: unknown): boolean {
|
|
75
|
+
return (
|
|
76
|
+
isRecord(value) &&
|
|
77
|
+
typeof value.name === "string" &&
|
|
78
|
+
(value.mode === "subagent" || value.mode === "primary" || value.mode === "all")
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Adapt the official OpenCode client to the controller's small deterministic boundary. */
|
|
83
|
+
export function createOpenCodeRuntime(
|
|
84
|
+
client: PluginInput["client"],
|
|
85
|
+
options: OpenCodeRuntimeOptions = {}
|
|
86
|
+
): PluginRuntime {
|
|
87
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_OPENCODE_REQUEST_TIMEOUT_MS;
|
|
88
|
+
const diagnosticTimeoutMs =
|
|
89
|
+
options.diagnosticTimeoutMs ?? DEFAULT_OPENCODE_DIAGNOSTIC_TIMEOUT_MS;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
async getMessages(projectDirectory, sessionID, limit) {
|
|
93
|
+
const response = await withDeadline("session.messages", requestTimeoutMs, (signal) =>
|
|
94
|
+
client.session.messages({
|
|
95
|
+
path: { id: sessionID },
|
|
96
|
+
query: { directory: projectDirectory, limit },
|
|
97
|
+
signal,
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
if (response.error !== undefined) throw response.error;
|
|
101
|
+
if (!Array.isArray(response.data) || !response.data.every(isSessionMessage)) {
|
|
102
|
+
throw new Error("OpenCode returned malformed session messages");
|
|
103
|
+
}
|
|
104
|
+
return response.data satisfies SessionMessagesResponse;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async getAgents(projectDirectory) {
|
|
108
|
+
const response = await withDeadline("app.agents", requestTimeoutMs, (signal) =>
|
|
109
|
+
client.app.agents({ query: { directory: projectDirectory }, signal })
|
|
110
|
+
);
|
|
111
|
+
if (response.error !== undefined) throw response.error;
|
|
112
|
+
if (!Array.isArray(response.data) || !response.data.every(isAgent)) {
|
|
113
|
+
throw new Error("OpenCode returned malformed agents");
|
|
114
|
+
}
|
|
115
|
+
return response.data satisfies Agent[];
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async prompt(projectDirectory, sessionID, body) {
|
|
119
|
+
const response = await withDeadline("session.prompt", requestTimeoutMs, (signal) =>
|
|
120
|
+
client.session.prompt({
|
|
121
|
+
path: { id: sessionID },
|
|
122
|
+
query: { directory: projectDirectory },
|
|
123
|
+
body,
|
|
124
|
+
signal,
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
if (response.error !== undefined) throw response.error;
|
|
128
|
+
if (response.data === undefined) throw new Error("OpenCode returned no prompt result");
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async prime(projectDirectory) {
|
|
132
|
+
return (options.prime ?? runBdPrime)(projectDirectory);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async diagnose(diagnostic) {
|
|
136
|
+
const extra =
|
|
137
|
+
diagnostic.code === "config_collision"
|
|
138
|
+
? { surface: diagnostic.surface, names: diagnostic.names }
|
|
139
|
+
: { sessionID: diagnostic.sessionID };
|
|
140
|
+
const response = await withDeadline("app.log", diagnosticTimeoutMs, (signal) =>
|
|
141
|
+
client.app.log({
|
|
142
|
+
query: { directory: diagnostic.directory },
|
|
143
|
+
body: {
|
|
144
|
+
service: "opencode-beads",
|
|
145
|
+
level: "warn",
|
|
146
|
+
message: diagnostic.code,
|
|
147
|
+
extra,
|
|
148
|
+
},
|
|
149
|
+
signal,
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
if (response.error !== undefined) throw response.error;
|
|
153
|
+
if (response.data === undefined) throw new Error("OpenCode returned no log result");
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/plugin-core.ts
CHANGED
|
@@ -25,7 +25,8 @@ export type SessionMessage = {
|
|
|
25
25
|
info: Pick<SessionMessageResponse["info"], "role"> &
|
|
26
26
|
Partial<Pick<UserMessage, "agent" | "model" | "system">>;
|
|
27
27
|
parts?: ReadonlyArray<
|
|
28
|
-
Pick<SessionMessageResponse["parts"][number], "type"> &
|
|
28
|
+
Pick<SessionMessageResponse["parts"][number], "type"> &
|
|
29
|
+
Partial<Pick<TextPart, "text" | "synthetic">>
|
|
29
30
|
>;
|
|
30
31
|
};
|
|
31
32
|
|
|
@@ -79,9 +80,28 @@ export type MutablePluginConfig = Parameters<NonNullable<Hooks["config"]>>[0];
|
|
|
79
80
|
export interface BeadsController {
|
|
80
81
|
onMessage(message: MessageContext, sink: ContextSink): Promise<void>;
|
|
81
82
|
onCompacted(sessionID: string): Promise<void>;
|
|
83
|
+
onSessionDeleted(sessionID: string): void;
|
|
82
84
|
configure(config: MutablePluginConfig): Promise<void>;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
type InjectionAudience = "primary" | "task-agent";
|
|
88
|
+
const BEADS_CONTEXT_HEADER = /^<beads-context(?: audience="(primary|task-agent)")?>\n/;
|
|
89
|
+
|
|
90
|
+
/** Recognize only the complete envelope emitted by this plugin. */
|
|
91
|
+
export function isBeadsContextEnvelope(text: string): boolean {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
return BEADS_CONTEXT_HEADER.test(trimmed) && trimmed.includes("\n</beads-context>");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function beadsContextAudience(text: string): InjectionAudience | undefined {
|
|
97
|
+
const audience = text.trim().match(BEADS_CONTEXT_HEADER)?.[1];
|
|
98
|
+
return audience === "primary" || audience === "task-agent" ? audience : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function injectionAudience(agentName: string | undefined): InjectionAudience {
|
|
102
|
+
return agentName === BEADS_TASK_AGENT ? "task-agent" : "primary";
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
/** Resolve OpenCode project scope without falling back to the process directory. */
|
|
86
106
|
export function resolveProjectDirectory(directory: string, worktree: string): string {
|
|
87
107
|
if (directory.trim()) return directory;
|
|
@@ -112,8 +132,8 @@ export async function createBeadsController(
|
|
|
112
132
|
const [loadedCommands, loadedAgents] = await Promise.all([loadCommands(), loadAgent()]);
|
|
113
133
|
const commands = loadedCommands ?? {};
|
|
114
134
|
const agents = loadedAgents ?? {};
|
|
115
|
-
const
|
|
116
|
-
const injectionAttempts = new Map<string, Promise<void
|
|
135
|
+
const injectedAudiences = new Map<string, Set<InjectionAudience>>();
|
|
136
|
+
const injectionAttempts = new Map<string, { promise: Promise<void>; cancelled: boolean }>();
|
|
117
137
|
const diagnosticTimes = new Map<string, number>();
|
|
118
138
|
const diagnosticIntervalMs = options.diagnosticIntervalMs ?? 60_000;
|
|
119
139
|
const now = options.now ?? Date.now;
|
|
@@ -124,7 +144,9 @@ export async function createBeadsController(
|
|
|
124
144
|
if (previousTime !== undefined && currentTime - previousTime < diagnosticIntervalMs) return;
|
|
125
145
|
|
|
126
146
|
diagnosticTimes.set(key, currentTime);
|
|
127
|
-
|
|
147
|
+
void Promise.resolve()
|
|
148
|
+
.then(() => runtime.diagnose(diagnostic))
|
|
149
|
+
.catch(() => undefined);
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
async function diagnoseSession(
|
|
@@ -162,8 +184,14 @@ export async function createBeadsController(
|
|
|
162
184
|
}
|
|
163
185
|
if (!output) return undefined;
|
|
164
186
|
|
|
165
|
-
const audience = context?.agent
|
|
166
|
-
return `<beads-context>\n${output}\n</beads-context>\n\n${beadsGuidance(audience)}`;
|
|
187
|
+
const audience = injectionAudience(context?.agent);
|
|
188
|
+
return `<beads-context audience="${audience}">\n${output}\n</beads-context>\n\n${beadsGuidance(audience)}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function markInjected(sessionID: string, audience: InjectionAudience): void {
|
|
192
|
+
const audiences = injectedAudiences.get(sessionID) ?? new Set<InjectionAudience>();
|
|
193
|
+
audiences.add(audience);
|
|
194
|
+
injectedAudiences.set(sessionID, audiences);
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
async function injectPrompt(
|
|
@@ -193,28 +221,41 @@ export async function createBeadsController(
|
|
|
193
221
|
}
|
|
194
222
|
}
|
|
195
223
|
|
|
196
|
-
async function performInitialInjection(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
224
|
+
async function performInitialInjection(
|
|
225
|
+
message: MessageContext,
|
|
226
|
+
sink: ContextSink,
|
|
227
|
+
isActive: () => boolean
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
if (!(await shouldInject(message.agent, message.sessionID))) return;
|
|
201
230
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
const audience = injectionAudience(message.agent);
|
|
232
|
+
const knownAudiences = injectedAudiences.get(message.sessionID);
|
|
233
|
+
if (knownAudiences?.has(audience)) return;
|
|
234
|
+
|
|
235
|
+
// On plugin reload there is no local audience state, so consult persisted envelopes.
|
|
236
|
+
if (!knownAudiences) {
|
|
237
|
+
try {
|
|
238
|
+
const existing = await runtime.getMessages(directory, message.sessionID);
|
|
239
|
+
const hasBeadsContext = existing?.some(
|
|
240
|
+
(item) =>
|
|
241
|
+
(item.info.system !== undefined &&
|
|
242
|
+
beadsContextAudience(item.info.system) === audience) ||
|
|
243
|
+
item.parts?.some(
|
|
244
|
+
(part) =>
|
|
245
|
+
part.type === "text" &&
|
|
246
|
+
part.synthetic === true &&
|
|
247
|
+
part.text !== undefined &&
|
|
248
|
+
beadsContextAudience(part.text) === audience
|
|
249
|
+
)
|
|
250
|
+
);
|
|
251
|
+
if (hasBeadsContext) {
|
|
252
|
+
if (isActive()) markInjected(message.sessionID, audience);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
await diagnoseSession("messages_lookup_failed", message.sessionID);
|
|
257
|
+
// Message lookup is advisory; injection remains the safe fallback.
|
|
214
258
|
}
|
|
215
|
-
} catch {
|
|
216
|
-
await diagnoseSession("messages_lookup_failed", message.sessionID);
|
|
217
|
-
// Message lookup is advisory; injection remains the safe fallback.
|
|
218
259
|
}
|
|
219
260
|
|
|
220
261
|
const rendered = await renderContext(message.sessionID, message);
|
|
@@ -222,7 +263,7 @@ export async function createBeadsController(
|
|
|
222
263
|
|
|
223
264
|
try {
|
|
224
265
|
await sink(rendered);
|
|
225
|
-
|
|
266
|
+
if (isActive()) markInjected(message.sessionID, audience);
|
|
226
267
|
} catch {
|
|
227
268
|
await diagnoseSession("prompt_failed", message.sessionID);
|
|
228
269
|
}
|
|
@@ -230,18 +271,21 @@ export async function createBeadsController(
|
|
|
230
271
|
|
|
231
272
|
return {
|
|
232
273
|
async onMessage(message, sink) {
|
|
233
|
-
|
|
274
|
+
const audience = injectionAudience(message.agent);
|
|
275
|
+
if (injectedAudiences.get(message.sessionID)?.has(audience)) return;
|
|
234
276
|
|
|
235
|
-
const
|
|
236
|
-
|
|
277
|
+
const attemptKey = `${message.sessionID}\0${audience}`;
|
|
278
|
+
const pending = injectionAttempts.get(attemptKey);
|
|
279
|
+
if (pending) return pending.promise;
|
|
237
280
|
|
|
238
|
-
const attempt =
|
|
239
|
-
|
|
281
|
+
const attempt = { promise: Promise.resolve(), cancelled: false };
|
|
282
|
+
attempt.promise = performInitialInjection(message, sink, () => !attempt.cancelled);
|
|
283
|
+
injectionAttempts.set(attemptKey, attempt);
|
|
240
284
|
try {
|
|
241
|
-
await attempt;
|
|
285
|
+
await attempt.promise;
|
|
242
286
|
} finally {
|
|
243
|
-
if (injectionAttempts.get(
|
|
244
|
-
injectionAttempts.delete(
|
|
287
|
+
if (injectionAttempts.get(attemptKey) === attempt) {
|
|
288
|
+
injectionAttempts.delete(attemptKey);
|
|
245
289
|
}
|
|
246
290
|
}
|
|
247
291
|
},
|
|
@@ -257,6 +301,19 @@ export async function createBeadsController(
|
|
|
257
301
|
}
|
|
258
302
|
},
|
|
259
303
|
|
|
304
|
+
onSessionDeleted(sessionID) {
|
|
305
|
+
injectedAudiences.delete(sessionID);
|
|
306
|
+
for (const [key, attempt] of injectionAttempts) {
|
|
307
|
+
if (key.startsWith(`${sessionID}\0`)) {
|
|
308
|
+
attempt.cancelled = true;
|
|
309
|
+
injectionAttempts.delete(key);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const key of diagnosticTimes.keys()) {
|
|
313
|
+
if (key.endsWith(`:${sessionID}`)) diagnosticTimes.delete(key);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
|
|
260
317
|
async configure(config) {
|
|
261
318
|
const commandCollisions = Object.keys(config.command ?? {})
|
|
262
319
|
.filter(
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Hooks } from "@opencode-ai/plugin";
|
|
2
|
+
import type { Part } from "@opencode-ai/sdk";
|
|
3
|
+
import {
|
|
4
|
+
createBeadsController,
|
|
5
|
+
isBeadsContextEnvelope,
|
|
6
|
+
type PluginRuntime,
|
|
7
|
+
} from "./plugin-core";
|
|
8
|
+
|
|
9
|
+
/** Prevent the synthetic no-reply prompt from recursively triggering its own injection. */
|
|
10
|
+
function isBeadsContextInjection(parts: ReadonlyArray<Part>): boolean {
|
|
11
|
+
return parts.some(
|
|
12
|
+
(part) =>
|
|
13
|
+
part.type === "text" &&
|
|
14
|
+
part.synthetic === true &&
|
|
15
|
+
isBeadsContextEnvelope(part.text)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build OpenCode hooks around an explicit runtime boundary. */
|
|
20
|
+
export async function createBeadsHooks(
|
|
21
|
+
runtime: PluginRuntime,
|
|
22
|
+
projectDirectory: string
|
|
23
|
+
): Promise<Hooks> {
|
|
24
|
+
const controller = await createBeadsController(runtime, projectDirectory);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
"chat.message": async (input, output) => {
|
|
28
|
+
if (isBeadsContextInjection(output.parts)) return;
|
|
29
|
+
|
|
30
|
+
await controller.onMessage(
|
|
31
|
+
{
|
|
32
|
+
sessionID: input.sessionID,
|
|
33
|
+
model: input.model,
|
|
34
|
+
agent: input.agent,
|
|
35
|
+
},
|
|
36
|
+
(context) => {
|
|
37
|
+
output.message.system = output.message.system
|
|
38
|
+
? `${output.message.system}\n\n${context}`
|
|
39
|
+
: context;
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
event: async ({ event }) => {
|
|
45
|
+
if (event.type === "session.compacted") {
|
|
46
|
+
await controller.onCompacted(event.properties.sessionID);
|
|
47
|
+
} else if (event.type === "session.deleted") {
|
|
48
|
+
controller.onSessionDeleted(event.properties.info.id);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
config: async (config) => {
|
|
53
|
+
await controller.configure(config);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,150 +1,12 @@
|
|
|
1
1
|
/** OpenCode adapter for the Beads issue tracker. */
|
|
2
2
|
|
|
3
|
-
import type { Plugin
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
resolveProjectDirectory,
|
|
8
|
-
type PluginRuntime,
|
|
9
|
-
} from "./plugin-core";
|
|
10
|
-
import { runBdPrime } from "./prime";
|
|
11
|
-
|
|
12
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
-
return typeof value === "object" && value !== null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function isSessionMessage(value: unknown): boolean {
|
|
17
|
-
if (
|
|
18
|
-
!isRecord(value) ||
|
|
19
|
-
!isRecord(value.info) ||
|
|
20
|
-
(value.info.role !== "user" && value.info.role !== "assistant") ||
|
|
21
|
-
(value.info.agent !== undefined && typeof value.info.agent !== "string") ||
|
|
22
|
-
(value.info.system !== undefined && typeof value.info.system !== "string") ||
|
|
23
|
-
(value.info.model !== undefined &&
|
|
24
|
-
(!isRecord(value.info.model) ||
|
|
25
|
-
typeof value.info.model.providerID !== "string" ||
|
|
26
|
-
typeof value.info.model.modelID !== "string"))
|
|
27
|
-
) {
|
|
28
|
-
return false;
|
|
29
|
-
}
|
|
30
|
-
if (!Array.isArray(value.parts)) return false;
|
|
31
|
-
|
|
32
|
-
return value.parts.every(
|
|
33
|
-
(part) =>
|
|
34
|
-
isRecord(part) &&
|
|
35
|
-
typeof part.type === "string" &&
|
|
36
|
-
(part.text === undefined || typeof part.text === "string")
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isAgent(value: unknown): boolean {
|
|
41
|
-
return (
|
|
42
|
-
isRecord(value) &&
|
|
43
|
-
typeof value.name === "string" &&
|
|
44
|
-
(value.mode === "subagent" || value.mode === "primary" || value.mode === "all")
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Prevent the synthetic no-reply prompt from recursively triggering its own injection. */
|
|
49
|
-
function isBeadsContextInjection(parts: ReadonlyArray<Part>): boolean {
|
|
50
|
-
return parts.some(
|
|
51
|
-
(part) =>
|
|
52
|
-
part.type === "text" &&
|
|
53
|
-
part.synthetic === true &&
|
|
54
|
-
part.text.includes("<beads-context>")
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Adapt the official OpenCode client to the controller's small deterministic boundary. */
|
|
59
|
-
export function createOpenCodeRuntime(client: PluginInput["client"]): PluginRuntime {
|
|
60
|
-
return {
|
|
61
|
-
async getMessages(projectDirectory, sessionID, limit) {
|
|
62
|
-
const response = await client.session.messages({
|
|
63
|
-
path: { id: sessionID },
|
|
64
|
-
query: { directory: projectDirectory, limit },
|
|
65
|
-
});
|
|
66
|
-
if (response.error !== undefined) throw response.error;
|
|
67
|
-
if (!Array.isArray(response.data) || !response.data.every(isSessionMessage)) {
|
|
68
|
-
throw new Error("OpenCode returned malformed session messages");
|
|
69
|
-
}
|
|
70
|
-
return response.data satisfies SessionMessagesResponse;
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
async getAgents(projectDirectory) {
|
|
74
|
-
const response = await client.app.agents({ query: { directory: projectDirectory } });
|
|
75
|
-
if (response.error !== undefined) throw response.error;
|
|
76
|
-
if (!Array.isArray(response.data) || !response.data.every(isAgent)) {
|
|
77
|
-
throw new Error("OpenCode returned malformed agents");
|
|
78
|
-
}
|
|
79
|
-
return response.data satisfies Agent[];
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
async prompt(projectDirectory, sessionID, body) {
|
|
83
|
-
const response = await client.session.prompt({
|
|
84
|
-
path: { id: sessionID },
|
|
85
|
-
query: { directory: projectDirectory },
|
|
86
|
-
body,
|
|
87
|
-
});
|
|
88
|
-
if (response.error !== undefined) throw response.error;
|
|
89
|
-
if (response.data === undefined) throw new Error("OpenCode returned no prompt result");
|
|
90
|
-
},
|
|
91
|
-
|
|
92
|
-
async prime(projectDirectory) {
|
|
93
|
-
return runBdPrime(projectDirectory);
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
async diagnose(diagnostic) {
|
|
97
|
-
const extra =
|
|
98
|
-
diagnostic.code === "config_collision"
|
|
99
|
-
? { surface: diagnostic.surface, names: diagnostic.names }
|
|
100
|
-
: { sessionID: diagnostic.sessionID };
|
|
101
|
-
const response = await client.app.log({
|
|
102
|
-
query: { directory: diagnostic.directory },
|
|
103
|
-
body: {
|
|
104
|
-
service: "opencode-beads",
|
|
105
|
-
level: "warn",
|
|
106
|
-
message: diagnostic.code,
|
|
107
|
-
extra,
|
|
108
|
-
},
|
|
109
|
-
});
|
|
110
|
-
if (response.error !== undefined) throw response.error;
|
|
111
|
-
if (response.data === undefined) throw new Error("OpenCode returned no log result");
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
}
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
|
+
import { resolveProjectDirectory } from "./plugin-core";
|
|
5
|
+
import { createBeadsHooks } from "./plugin-hooks";
|
|
6
|
+
import { createOpenCodeRuntime } from "./opencode-runtime";
|
|
115
7
|
|
|
116
8
|
export const BeadsPlugin: Plugin = async ({ client, directory, worktree }) => {
|
|
117
9
|
const projectDirectory = resolveProjectDirectory(directory, worktree);
|
|
118
10
|
const runtime = createOpenCodeRuntime(client);
|
|
119
|
-
|
|
120
|
-
const controller = await createBeadsController(runtime, projectDirectory);
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
"chat.message": async (input, output) => {
|
|
124
|
-
if (isBeadsContextInjection(output.parts)) return;
|
|
125
|
-
|
|
126
|
-
await controller.onMessage(
|
|
127
|
-
{
|
|
128
|
-
sessionID: input.sessionID,
|
|
129
|
-
model: input.model,
|
|
130
|
-
agent: input.agent,
|
|
131
|
-
},
|
|
132
|
-
(context) => {
|
|
133
|
-
output.message.system = output.message.system
|
|
134
|
-
? `${output.message.system}\n\n${context}`
|
|
135
|
-
: context;
|
|
136
|
-
}
|
|
137
|
-
);
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
event: async ({ event }) => {
|
|
141
|
-
if (event.type === "session.compacted") {
|
|
142
|
-
await controller.onCompacted(event.properties.sessionID);
|
|
143
|
-
}
|
|
144
|
-
},
|
|
145
|
-
|
|
146
|
-
config: async (config) => {
|
|
147
|
-
await controller.configure(config);
|
|
148
|
-
},
|
|
149
|
-
};
|
|
11
|
+
return createBeadsHooks(runtime, projectDirectory);
|
|
150
12
|
};
|
package/src/prime.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { kill as killProcess } from "node:process";
|
|
2
|
+
|
|
1
3
|
export const DEFAULT_PRIME_TIMEOUT_MS = 10_000;
|
|
4
|
+
export const DEFAULT_PRIME_DRAIN_TIMEOUT_MS = 1_000;
|
|
2
5
|
|
|
3
6
|
export class PrimeTimeoutError extends Error {
|
|
4
7
|
constructor(timeoutMs: number) {
|
|
@@ -15,25 +18,55 @@ export class PrimeProcessError extends Error {
|
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export interface PrimeProcess {
|
|
21
|
+
pid: number;
|
|
18
22
|
stdout: ReadableStream<Uint8Array>;
|
|
19
23
|
stderr: ReadableStream<Uint8Array>;
|
|
20
24
|
exited: Promise<number>;
|
|
21
25
|
kill(signal?: NodeJS.Signals): void;
|
|
26
|
+
killTree?(signal?: NodeJS.Signals): void;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export interface PrimeExecutionOptions {
|
|
25
30
|
timeoutMs?: number;
|
|
31
|
+
drainTimeoutMs?: number;
|
|
26
32
|
spawn?: (directory: string, args: readonly string[]) => PrimeProcess;
|
|
27
33
|
scheduleTimeout?: (callback: () => void, delayMs: number) => () => void;
|
|
34
|
+
scheduleDrainTimeout?: (callback: () => void, delayMs: number) => () => void;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
function spawnPrime(directory: string, args: readonly string[]): PrimeProcess {
|
|
31
|
-
|
|
38
|
+
const child = Bun.spawn(["bd", "prime", ...args], {
|
|
32
39
|
cwd: directory,
|
|
33
40
|
stdin: "ignore",
|
|
34
41
|
stdout: "pipe",
|
|
35
42
|
stderr: "pipe",
|
|
43
|
+
detached: true,
|
|
36
44
|
});
|
|
45
|
+
let processGroupID: number | undefined;
|
|
46
|
+
if (process.platform !== "win32") {
|
|
47
|
+
const group = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(child.pid)], {
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "ignore",
|
|
50
|
+
timeout: 100,
|
|
51
|
+
});
|
|
52
|
+
const parsed = Number.parseInt(new TextDecoder().decode(group.stdout).trim(), 10);
|
|
53
|
+
if (group.exitCode === 0 && parsed === child.pid && parsed > 1) processGroupID = parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
pid: child.pid,
|
|
58
|
+
stdout: child.stdout,
|
|
59
|
+
stderr: child.stderr,
|
|
60
|
+
exited: child.exited,
|
|
61
|
+
kill: (signal) => child.kill(signal),
|
|
62
|
+
killTree: (signal) => {
|
|
63
|
+
if (processGroupID !== undefined) {
|
|
64
|
+
killProcess(-processGroupID, signal);
|
|
65
|
+
} else {
|
|
66
|
+
child.kill(signal);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
};
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
function scheduleTimeout(callback: () => void, delayMs: number): () => void {
|
|
@@ -41,15 +74,42 @@ function scheduleTimeout(callback: () => void, delayMs: number): () => void {
|
|
|
41
74
|
return () => clearTimeout(timer);
|
|
42
75
|
}
|
|
43
76
|
|
|
77
|
+
function terminateProcessTree(child: PrimeProcess): void {
|
|
78
|
+
try {
|
|
79
|
+
(child.killTree ?? child.kill)("SIGKILL");
|
|
80
|
+
} catch {
|
|
81
|
+
try {
|
|
82
|
+
child.kill("SIGKILL");
|
|
83
|
+
} catch {
|
|
84
|
+
// Timeout remains authoritative if the process exited concurrently.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function drainAfterKill(
|
|
90
|
+
completion: Promise<unknown>,
|
|
91
|
+
timeoutMs: number,
|
|
92
|
+
scheduler: (callback: () => void, delayMs: number) => () => void
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
await new Promise<void>((resolve) => {
|
|
95
|
+
const cancelTimeout = scheduler(resolve, timeoutMs);
|
|
96
|
+
void completion.finally(() => {
|
|
97
|
+
cancelTimeout();
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
44
103
|
async function runPrimeAttempt(
|
|
45
104
|
directory: string,
|
|
46
105
|
args: readonly string[],
|
|
47
106
|
options: PrimeExecutionOptions = {}
|
|
48
107
|
): Promise<string> {
|
|
49
108
|
const timeoutMs = options.timeoutMs ?? DEFAULT_PRIME_TIMEOUT_MS;
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
109
|
+
const drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_PRIME_DRAIN_TIMEOUT_MS;
|
|
110
|
+
const child = (options.spawn ?? spawnPrime)(directory, args);
|
|
111
|
+
const stdout = new Response(child.stdout).text();
|
|
112
|
+
const stderr = new Response(child.stderr).text();
|
|
53
113
|
|
|
54
114
|
let timedOut = false;
|
|
55
115
|
let rejectTimeout: (error: PrimeTimeoutError) => void = () => {};
|
|
@@ -58,11 +118,11 @@ async function runPrimeAttempt(
|
|
|
58
118
|
});
|
|
59
119
|
const cancelTimeout = (options.scheduleTimeout ?? scheduleTimeout)(() => {
|
|
60
120
|
timedOut = true;
|
|
61
|
-
|
|
121
|
+
terminateProcessTree(child);
|
|
62
122
|
rejectTimeout(new PrimeTimeoutError(timeoutMs));
|
|
63
123
|
}, timeoutMs);
|
|
64
124
|
|
|
65
|
-
const completion = Promise.all([
|
|
125
|
+
const completion = Promise.all([child.exited, stdout, stderr]).then(
|
|
66
126
|
([exitCode, output, errorOutput]) => {
|
|
67
127
|
if (exitCode !== 0) throw new PrimeProcessError(exitCode, errorOutput);
|
|
68
128
|
return output;
|
|
@@ -74,7 +134,11 @@ async function runPrimeAttempt(
|
|
|
74
134
|
} finally {
|
|
75
135
|
cancelTimeout();
|
|
76
136
|
if (timedOut) {
|
|
77
|
-
await
|
|
137
|
+
await drainAfterKill(
|
|
138
|
+
completion.catch(() => undefined),
|
|
139
|
+
drainTimeoutMs,
|
|
140
|
+
options.scheduleDrainTimeout ?? scheduleTimeout
|
|
141
|
+
);
|
|
78
142
|
}
|
|
79
143
|
}
|
|
80
144
|
}
|
package/vendor/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"repository": "https://github.com/gastownhall/beads.git",
|
|
4
|
-
"tag": "v1.1.
|
|
5
|
-
"commit": "
|
|
4
|
+
"tag": "v1.1.2",
|
|
5
|
+
"commit": "20e493e569c922d1253bdeff068c5e56c94957fb",
|
|
6
6
|
"sources": [
|
|
7
7
|
{
|
|
8
8
|
"source": "plugins/beads/agents/task-agent.md",
|