@snarkipus/opencode-beads 0.9.1 → 0.9.3

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
@@ -2,124 +2,87 @@
2
2
 
3
3
  [Beads](https://github.com/gastownhall/beads) issue tracker integration for [OpenCode](https://opencode.ai).
4
4
 
5
- This maintained fork provides the OpenCode-facing product layer around Beads: full `bd prime` context injection, vendored workflows, an autonomous task agent, and project-scoped runtime integration. It is deliberately an adapter rather than a second issue tracker: the `bd` CLI remains authoritative for issues, project initialization, skills, Dolt synchronization, migrations, and general Beads behavior.
5
+ `@snarkipus/opencode-beads` is a maintained OpenCode adapter that provides full `bd prime` context injection, vendored `/beads:*` workflows, and a bounded task agent. It does not replace Beads: the installed `bd` CLI remains authoritative for issues, project initialization, skills, migrations, backups, and Dolt synchronization.
6
6
 
7
7
  The project originated as [Josh Thomas's `opencode-beads`](https://github.com/joshuadavidthomas/opencode-beads) and continues under the MIT license. This fork is maintained by Matt Jackson and tracks reviewed upstream Beads releases while adapting their plugin artifacts to OpenCode's CLI-only execution model.
8
8
 
9
- ## Installation
10
-
11
- Install the `bd` CLI once on the host:
12
-
13
- ```bash
14
- curl -fsSL https://raw.githubusercontent.com/gastownhall/beads/main/scripts/install.sh | bash
15
- ```
9
+ ## Design
16
10
 
17
- See the [Beads installation guide](https://github.com/gastownhall/beads/blob/main/docs/getting-started/installation.md) for alternative methods (Homebrew, Windows, AUR, etc.).
11
+ The plugin's primary design is **context-preserving atomic delegation**:
18
12
 
19
- Add the version-pinned plugin to your OpenCode config (`~/.config/opencode/opencode.json`):
13
+ - The primary OpenCode thread retains planning context, decisions, and control.
14
+ - `beads-task-agent` processes exactly one Bead per invocation, then closes, blocks, or returns.
15
+ - Without an external orchestrator, this intentionally trades broad autonomy for Bead atomicity and context preservation.
16
+ - An orchestrator may repeat `select → delegate one Bead → verify → select next`, but should not weaken the worker boundary.
20
17
 
21
- ```json
22
- {
23
- "plugin": ["@snarkipus/opencode-beads@0.9.1"]
24
- }
25
- ```
26
-
27
- Restart OpenCode. OpenCode caches pinned plugins, so update this version explicitly when upgrading. An unpinned `@snarkipus/opencode-beads` entry follows npm updates on startup but is less reproducible.
18
+ ## Installation
28
19
 
29
- Initialize every new project separately. Create a Git worktree first if necessary, then run `bd init`:
20
+ Install the [`bd` CLI](https://github.com/gastownhall/beads/blob/main/docs/getting-started/installation.md) once on the host, then initialize each project:
30
21
 
31
22
  ```bash
32
- git init
23
+ git init # only when the directory is not already a Git worktree
33
24
  bd init
34
25
  ```
35
26
 
36
- Skip `git init` when the project is already a Git worktree. `bd init` performs the per-project Beads initialization and its automatic Codex project integration creates the canonical shared skill at `.agents/skills/beads`, which OpenCode also discovers. `/beads:init` invokes the same project initialization workflow; this plugin has no separate skill lifecycle or companion CLI.
37
-
38
- ### Upgrading from 0.8.0
27
+ `bd init` owns project initialization and creates the canonical shared Beads skill at `.agents/skills/beads`, which OpenCode discovers. This plugin has no separate skill lifecycle and does not write startup files.
39
28
 
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.1:
41
-
42
- ```bash
43
- bunx @snarkipus/opencode-beads@0.8.0 remove
44
- ```
29
+ Add the version-pinned plugin to `~/.config/opencode/opencode.json`:
45
30
 
46
- If the old skill was installed globally, append `--global` to that exact command. Do not substitute 0.9.1 in the removal command: the companion CLI no longer exists in 0.9.1.
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"
31
+ ```json
32
+ {
33
+ "plugin": ["@snarkipus/opencode-beads@0.9.3"]
34
+ }
57
35
  ```
58
36
 
59
- This bounded migration copies only `.agents/skills/beads`; it does not copy `.codex` artifacts or generated instruction files.
37
+ Restart OpenCode. Update the pin explicitly when upgrading; an unpinned package entry is less reproducible.
60
38
 
61
- ## Features
39
+ ## Context injection
62
40
 
63
- - **Context injection** - Loads the canonical Beads workflow and persistent memories on session start and after compaction
64
- - **Commands** - Vendored Beads workflows available under the `/beads:*` namespace
65
- - **Task agent** - Autonomous issue completion via `beads-task-agent` subagent
41
+ The plugin runs full `bd prime` when a primary-agent session first receives a message and after compaction. Prime supplies the canonical current workflow, command guidance, and persistent memories; the plugin adds only compact OpenCode-specific safety and delegation guidance.
66
42
 
67
- ## Usage
43
+ Known regular subagents remain excluded, including `explore` and `general`, while `beads-task-agent` remains eligible. If agent lookup fails, context injection fails open to preserve compatibility with unknown custom primary agents.
68
44
 
69
- This plugin is a thin OpenCode adapter. For Beads workflows, CLI commands, Dolt operations, migrations, backups, and issue-tracking concepts, use the [upstream documentation](https://github.com/gastownhall/beads) or run `bd prime`.
45
+ If `bd` is unavailable, the project is uninitialized, or prime fails or returns no content, injection is silently skipped and remains retryable. Vendored commands stay available, and the task agent retains a bounded standalone quick reference for `ready`, `show`, atomic claim, discovered follow-up, validate before closure, and close.
70
46
 
71
- ### Context behavior
47
+ ## Commands
72
48
 
73
- The plugin runs full `bd prime` when a primary-agent session first receives a message and after compaction, matching the canonical workflow pattern used by the upstream Claude Code and Codex integrations. The prime output supplies the current workflow, command guidance, and persistent memories; a compact shared layer adds only OpenCode-specific CLI safety and primary-agent delegation. Regular task subagents such as `explore` and `general` are deliberately skipped, while the included `beads-task-agent` remains explicitly eligible.
49
+ Vendored workflows are registered under `/beads:*`, including `/beads:ready`, `/beads:create`, and `/beads:show`. The plugin does not generate a command for every `bd` subcommand; use the CLI and the generated [Beads CLI reference](https://beads.gascity.com/cli-reference/index) for the complete surface.
74
50
 
75
- If `bd` is unavailable, the project is not initialized, or prime fails or returns no content, context injection is silently skipped and remains retryable. Vendored commands remain visible, the runtime does not initialize Beads or write startup files, and the task agent retains a bounded standalone quick reference for `ready`, `show`, atomic claim, discovered follow-up, and close. That fallback tells the agent to run `bd prime` when injected context is missing or stale; it does not duplicate the full live workflow.
51
+ Explicit command and agent definitions in OpenCode configuration take precedence over plugin definitions with the same name. Exact `beads:*` or `beads-task-agent` collisions produce a grouped, rate-limited warning without preventing non-conflicting definitions from loading.
76
52
 
77
- ## Commands
53
+ ## Task agent
78
54
 
79
- Commands are available as `/beads:<name>`, for example `/beads:ready`, `/beads:create`, and `/beads:show`. The plugin vendors every command template published by the upstream Beads plugin; it does not generate an OpenCode command for every `bd` subcommand. Use the `bd` CLI for the complete command surface and consult the generated [CLI reference](https://beads.gascity.com/cli-reference/index).
55
+ `beads-task-agent` supports read-only status and graph analysis or one-Bead completion. Analysis requests never mutate Beads. Completion requests inspect and process one caller-selected or highest-priority ready Bead, quarantine work discovered during that invocation, validate before closure, and return after closing or blocking the selected Bead.
80
56
 
81
- Explicit command and agent definitions in your OpenCode configuration take precedence over plugin-provided definitions with the same name. The plugin emits a grouped, rate-limited warning for exact `beads:*` or `beads-task-agent` collisions while continuing to register every non-conflicting definition.
57
+ Its runtime prompt is a fork-owned bounded OpenCode adaptation. The vendored upstream task-agent artifact supplies metadata, provenance, and compatibility validation; it is not executed as upstream's autonomous multi-task loop.
82
58
 
83
- ## Agent
59
+ ## Vendored content
84
60
 
85
- ### beads-task-agent
61
+ [`scripts/sync-beads.sh`](scripts/sync-beads.sh) copies the reviewed upstream command templates and task-agent artifact into `vendor/`. [`vendor/manifest.json`](vendor/manifest.json) pins the upstream tag and commit and records source paths, sorted inventory, byte lengths, and SHA-256 checksums. A deterministic adaptation layer translates reviewed host-specific instructions to OpenCode's CLI-only model.
86
62
 
87
- A subagent for read-only status and graph analysis or one-bead task completion. Analysis requests never mutate beads; completion requests inspect and process one caller-selected or highest-priority ready bead, quarantine work created during that invocation, validate before closure, and return after closing or blocking the selected bead. Its configured prompt is role-specific; session injection supplies compact OpenCode-specific CLI, validation, delegation, and conservative commit/push/sync policy without duplicating `bd prime`.
63
+ Do not edit vendored files directly. Sync and package validation reject provenance, checksum, inventory, schema, or reviewed-transformation drift. Adapter behavior lives in `src/`; general Beads behavior and documentation remain upstream.
88
64
 
89
65
  ## Compatibility
90
66
 
91
- The compatibility ranges and validated baselines for this release are:
92
-
93
67
  | Component | Expected range | Validated baseline |
94
68
  | --- | --- | --- |
95
- | OpenCode | 1.18.3 through 1.x | 1.18.4 |
96
- | `bd` CLI | 1.0.5 through 1.x | 1.1.0 |
69
+ | OpenCode | 1.18.3 through 1.x | 1.18.15 |
70
+ | `bd` CLI | 1.0.5 through 1.x | 1.1.2 |
97
71
  | Bun | >=1.3.14 | 1.3.14 |
98
72
 
99
- 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.
100
-
101
- ## Vendored Content
102
-
103
- Files under `vendor/` are copied from the upstream Beads plugin by [`scripts/sync-beads.sh`](scripts/sync-beads.sh). The current inventory includes the complete upstream command-template directory and task agent, rather than a duplicate of the much larger `bd` CLI. [`vendor/manifest.json`](vendor/manifest.json) records the stable upstream tag and commit, source paths, sorted inventory, byte lengths, and SHA-256 checksums. A deterministic adaptation layer translates known MCP- or Claude-specific instructions to OpenCode's CLI-only model when prompts load; sync fails if provenance, checksums, inventory, or reviewed transformations differ. Do not edit vendored files directly: the next sync replaces them. Adapter behavior lives in `src/`, while general Beads behavior and documentation remain upstream.
104
-
105
- The vendor manifest, task agent, and every recorded command file are required package content. Their exact upstream source paths, inventory, byte lengths, and hashes are strict package inputs. Plugin loading fails with the artifact path and validation reason if required content is missing or malformed. Command frontmatter supports `description`, `argument-hint`, `agent`, `model`, and `subtask`; task-agent frontmatter supports `description`, `mode: subagent`, `model`, `temperature`, `top_p`, `disable`, `color`, and `maxSteps`. Unsupported fields or shapes fail validation so upstream changes receive explicit review. Reinstall the package or rerun the sync script from a source checkout rather than repairing generated files by hand.
73
+ The adapter builds against paired `@opencode-ai/plugin` and `@opencode-ai/sdk` 1.18.15 releases and declares compatible optional peers from 1.18.3 through stable 1.x. Command and agent provenance is synced from Beads v1.2.1.
106
74
 
107
75
  ## Troubleshooting
108
76
 
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.
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.
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.
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.
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.
115
- - **Behavior changed after an upgrade:** Compare `opencode --version`, `bd version`, and `bun --version` with the compatibility table, then check the [Beads releases](https://github.com/gastownhall/beads/releases) and this project's changelog.
77
+ - **No Beads context:** Run `bd prime`. Install `bd` if missing or run `bd init` if the project has no Beads workspace.
78
+ - **Canonical skill missing:** For a new workspace, run `bd init`. For an existing workspace, follow current upstream Beads guidance; this plugin does not install or repair skills.
79
+ - **Vendor initialization error:** Reinstall the package. In a source checkout, rerun vendor sync and validation; the error identifies the malformed or missing artifact.
80
+ - **Unexpected definition:** Check OpenCode configuration for a colliding `beads:*` command or `beads-task-agent` definition. Explicit configuration wins.
81
+ - **Regular subagent has no context:** This is intentional. Delegate Beads work to `beads-task-agent` or run the required `bd` command explicitly.
82
+ - **Behavior changed after upgrade:** Compare `opencode --version`, `bd version`, and `bun --version` with the compatibility table, then inspect [Beads releases](https://github.com/gastownhall/beads/releases) and this project's changelog.
116
83
 
117
84
  ## License
118
85
 
119
- opencode-beads is licensed under the MIT license. See the [`LICENSE`](LICENSE) file for more information.
120
-
121
- ---
122
-
123
- opencode-beads is not built by, or affiliated with, the OpenCode team.
86
+ opencode-beads is licensed under the MIT license. See [`LICENSE`](LICENSE).
124
87
 
125
- OpenCode is ©2025 Anomaly.
88
+ opencode-beads is not built by or affiliated with the OpenCode team. OpenCode is ©2025 Anomaly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snarkipus/opencode-beads",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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
+ }