@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 +41 -78
- 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/commands/audit.md +1 -2
- package/vendor/commands/blocked.md +1 -1
- package/vendor/commands/close.md +1 -1
- package/vendor/commands/comments.md +1 -1
- package/vendor/commands/compact.md +1 -1
- package/vendor/commands/create.md +1 -1
- package/vendor/commands/decision.md +2 -2
- package/vendor/commands/delete.md +1 -1
- package/vendor/commands/dep.md +1 -1
- package/vendor/commands/epic.md +4 -1
- package/vendor/commands/export.md +2 -2
- package/vendor/commands/import.md +75 -9
- package/vendor/commands/init.md +1 -1
- package/vendor/commands/label.md +1 -1
- package/vendor/commands/list.md +1 -1
- package/vendor/commands/quickstart.md +2 -2
- package/vendor/commands/rename-prefix.md +1 -2
- package/vendor/commands/reopen.md +1 -1
- package/vendor/commands/restore.md +1 -1
- package/vendor/commands/search.md +1 -1
- package/vendor/commands/show.md +1 -1
- package/vendor/commands/sync.md +101 -9
- package/vendor/commands/update.md +1 -1
- package/vendor/manifest.json +47 -47
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
11
|
+
The plugin's primary design is **context-preserving atomic delegation**:
|
|
18
12
|
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
37
|
+
Restart OpenCode. Update the pin explicitly when upgrading; an unpinned package entry is less reproducible.
|
|
60
38
|
|
|
61
|
-
##
|
|
39
|
+
## Context injection
|
|
62
40
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
## Commands
|
|
72
48
|
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
53
|
+
## Task agent
|
|
78
54
|
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
59
|
+
## Vendored content
|
|
84
60
|
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
96
|
-
| `bd` CLI | 1.0.5 through 1.x | 1.1.
|
|
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
|
|
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
|
|
110
|
-
- **Canonical skill
|
|
111
|
-
- **
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|