@snarkipus/opencode-beads 0.9.0 → 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 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.0"]
23
+ "plugin": ["@snarkipus/opencode-beads@0.9.2"]
24
24
  }
25
25
  ```
26
26
 
@@ -37,13 +37,26 @@ 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.0:
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. After removal, run `bd init` in each project that has not yet received the canonical shared skill. Do not substitute 0.9.0 in the removal command: the companion CLI no longer exists in 0.9.0.
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
+
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
+
50
+ ```bash
51
+ scratch="$(mktemp -d)"
52
+ git -C "$scratch" init
53
+ (cd "$scratch" && bd init)
54
+ mkdir -p .agents/skills
55
+ cp -R "$scratch/.agents/skills/beads" .agents/skills/beads
56
+ rm -rf "$scratch"
57
+ ```
58
+
59
+ This bounded migration copies only `.agents/skills/beads`; it does not copy `.codex` artifacts or generated instruction files.
47
60
 
48
61
  ## Features
49
62
 
@@ -79,11 +92,11 @@ The compatibility ranges and validated baselines for this release are:
79
92
 
80
93
  | Component | Expected range | Validated baseline |
81
94
  | --- | --- | --- |
82
- | OpenCode | 1.18.3 through 1.x | 1.18.4 |
83
- | `bd` CLI | 1.0.5 through 1.x | 1.1.0 |
95
+ | OpenCode | 1.18.3 through 1.x | 1.18.15 |
96
+ | `bd` CLI | 1.0.5 through 1.x | 1.1.2 |
84
97
  | Bun | >=1.3.14 | 1.3.14 |
85
98
 
86
- The OpenCode adapter builds against the exact paired `@opencode-ai/plugin` and `@opencode-ai/sdk` `1.18.4` 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.0. Newer compatible releases may work but are not guaranteed until validated; when diagnosing a regression, reproduce it with the baselines above.
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.
87
100
 
88
101
  ## Vendored Content
89
102
 
@@ -94,8 +107,8 @@ The vendor manifest, task agent, and every recorded command file are required pa
94
107
  ## Troubleshooting
95
108
 
96
109
  - **No Beads context:** Run `bd prime` in the project. Install `bd` if the command is missing, or run `bd init` if the project has no Beads workspace.
97
- - **Canonical skill is missing:** Confirm the project is a Git worktree, then run `bd init`. The automatic Codex project integration creates `.agents/skills/beads`.
98
- - **Upgrading from 0.8.0:** Run the exact version-pinned removal command in the upgrade section before updating the plugin pin, then use `bd init` for canonical per-project setup.
110
+ - **Canonical skill is missing:** For a repository without a Beads workspace, run `bd init`. For an already-initialized repository, use the bounded manual-copy migration above; upstream currently has no skill-only setup command, and `bd setup codex` installs additional Codex integration artifacts.
111
+ - **Upgrading from 0.8.0:** Run the exact version-pinned removal command in the upgrade section before updating the plugin pin, then use the appropriate fresh-initialization or existing-workspace path above.
99
112
  - **Vendor artifact initialization error:** Reinstall the package. If using a source checkout, rerun the vendor sync and validation scripts; the error names the missing or malformed manifest, command, or task-agent file.
100
113
  - **Unexpected command or agent definition:** Check the OpenCode configuration for a colliding `beads:*` or `beads-task-agent` entry. Your explicit configuration wins on an exact name collision; OpenCode logs a rate-limited warning naming the collision.
101
114
  - **A regular subagent has no Beads context:** This is intentional. Delegate Beads work to `beads-task-agent`, or run the required `bd` command explicitly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snarkipus/opencode-beads",
3
- "version": "0.9.0",
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.4",
69
- "@opencode-ai/sdk": "1.18.4",
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
+ }
@@ -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"> & Partial<Pick<TextPart, "text">>
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 injectedSessions = new Set<string>();
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
- await runtime.diagnose(diagnostic).catch(() => undefined);
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 === BEADS_TASK_AGENT ? "task-agent" : "primary";
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(message: MessageContext, sink: ContextSink): Promise<void> {
197
- if (!(await shouldInject(message.agent, message.sessionID))) {
198
- injectedSessions.add(message.sessionID);
199
- return;
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
- try {
203
- const existing = await runtime.getMessages(directory, message.sessionID);
204
- const hasBeadsContext = existing?.some(
205
- (item) =>
206
- item.info.system?.includes("<beads-context>") ||
207
- item.parts?.some(
208
- (part) => part.type === "text" && part.text?.includes("<beads-context>")
209
- )
210
- );
211
- if (hasBeadsContext) {
212
- injectedSessions.add(message.sessionID);
213
- return;
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
- injectedSessions.add(message.sessionID);
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
- if (injectedSessions.has(message.sessionID)) return;
274
+ const audience = injectionAudience(message.agent);
275
+ if (injectedAudiences.get(message.sessionID)?.has(audience)) return;
234
276
 
235
- const pending = injectionAttempts.get(message.sessionID);
236
- if (pending) return pending;
277
+ const attemptKey = `${message.sessionID}\0${audience}`;
278
+ const pending = injectionAttempts.get(attemptKey);
279
+ if (pending) return pending.promise;
237
280
 
238
- const attempt = performInitialInjection(message, sink);
239
- injectionAttempts.set(message.sessionID, attempt);
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(message.sessionID) === attempt) {
244
- injectionAttempts.delete(message.sessionID);
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, PluginInput } from "@opencode-ai/plugin";
4
- import type { Agent, Part, SessionMessagesResponse } from "@opencode-ai/sdk";
5
- import {
6
- createBeadsController,
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
- return Bun.spawn(["bd", "prime", ...args], {
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 process = (options.spawn ?? spawnPrime)(directory, args);
51
- const stdout = new Response(process.stdout).text();
52
- const stderr = new Response(process.stderr).text();
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
- process.kill("SIGKILL");
121
+ terminateProcessTree(child);
62
122
  rejectTimeout(new PrimeTimeoutError(timeoutMs));
63
123
  }, timeoutMs);
64
124
 
65
- const completion = Promise.all([process.exited, stdout, stderr]).then(
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 completion.catch(() => undefined);
137
+ await drainAfterKill(
138
+ completion.catch(() => undefined),
139
+ drainTimeoutMs,
140
+ options.scheduleDrainTimeout ?? scheduleTimeout
141
+ );
78
142
  }
79
143
  }
80
144
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "repository": "https://github.com/gastownhall/beads.git",
4
- "tag": "v1.1.0",
5
- "commit": "8e4e59d39f3459a43cf21a3236a13eca4dd874f7",
4
+ "tag": "v1.1.2",
5
+ "commit": "20e493e569c922d1253bdeff068c5e56c94957fb",
6
6
  "sources": [
7
7
  {
8
8
  "source": "plugins/beads/agents/task-agent.md",