@snarkipus/opencode-beads 0.10.0 → 1.0.1
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 +104 -42
- package/package.json +14 -13
- package/src/agents.ts +82 -0
- package/src/context.ts +148 -0
- package/src/guard.ts +138 -0
- package/src/guidance.ts +53 -0
- package/src/host.ts +140 -0
- package/src/instructions.ts +49 -0
- package/src/plugin.ts +9 -9
- package/src/prime.ts +23 -12
- package/src/opencode-runtime.ts +0 -156
- package/src/plugin-core.ts +0 -371
- package/src/plugin-hooks.ts +0 -56
- package/src/vendor-adaptations.ts +0 -146
- package/src/vendor-provenance.ts +0 -252
- package/src/vendor.ts +0 -277
- package/vendor/agents/task-agent.md +0 -61
- package/vendor/commands/audit.md +0 -28
- package/vendor/commands/blocked.md +0 -15
- package/vendor/commands/close.md +0 -18
- package/vendor/commands/comments.md +0 -30
- package/vendor/commands/compact.md +0 -31
- package/vendor/commands/create.md +0 -19
- package/vendor/commands/decision.md +0 -111
- package/vendor/commands/delete.md +0 -32
- package/vendor/commands/dep.md +0 -101
- package/vendor/commands/epic.md +0 -27
- package/vendor/commands/export.md +0 -25
- package/vendor/commands/import.md +0 -18
- package/vendor/commands/init.md +0 -18
- package/vendor/commands/label.md +0 -33
- package/vendor/commands/list.md +0 -64
- package/vendor/commands/prime.md +0 -10
- package/vendor/commands/quickstart.md +0 -13
- package/vendor/commands/ready.md +0 -15
- package/vendor/commands/rename-prefix.md +0 -24
- package/vendor/commands/reopen.md +0 -22
- package/vendor/commands/restore.md +0 -27
- package/vendor/commands/search.md +0 -103
- package/vendor/commands/show.md +0 -17
- package/vendor/commands/stats.md +0 -17
- package/vendor/commands/sync.md +0 -17
- package/vendor/commands/template.md +0 -337
- package/vendor/commands/update.md +0 -24
- package/vendor/commands/version.md +0 -26
- package/vendor/commands/workflow.md +0 -58
- package/vendor/manifest.json +0 -168
package/src/guard.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Oldest Beads release this adapter supports. */
|
|
5
|
+
export const MINIMUM_BD_VERSION = "1.3.0";
|
|
6
|
+
|
|
7
|
+
export type Version = readonly [number, number, number];
|
|
8
|
+
|
|
9
|
+
/** Outcome of checking whether automatic `bd prime` may open the project store. */
|
|
10
|
+
export type GuardResult =
|
|
11
|
+
| { kind: "ready" }
|
|
12
|
+
| { kind: "uninitialized" }
|
|
13
|
+
| { kind: "unavailable" }
|
|
14
|
+
| { kind: "unsupported"; installed: string }
|
|
15
|
+
| { kind: "migration-pending"; installed: string; store: string | undefined };
|
|
16
|
+
|
|
17
|
+
export interface GuardRuntime {
|
|
18
|
+
/** Run `bd` with arguments in the project directory and return stdout. */
|
|
19
|
+
bd(directory: string, args: readonly string[]): Promise<string>;
|
|
20
|
+
/** Read a UTF-8 file, resolving undefined when it does not exist. */
|
|
21
|
+
readText(path: string): Promise<string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GuardOptions {
|
|
25
|
+
/** Let automatic prime apply pending schema migrations instead of withholding context. */
|
|
26
|
+
autoMigrate?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Parse the leading `major.minor.patch` triple; pre-release suffixes are ignored. */
|
|
30
|
+
export function parseVersion(text: string): Version | undefined {
|
|
31
|
+
const match = text.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
32
|
+
if (!match) return undefined;
|
|
33
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Compare two parsed versions; negative when `left` is older. */
|
|
37
|
+
export function compareVersions(left: Version, right: Version): number {
|
|
38
|
+
for (let index = 0; index < 3; index++) {
|
|
39
|
+
const difference = (left[index] ?? 0) - (right[index] ?? 0);
|
|
40
|
+
if (difference !== 0) return difference;
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatVersion(version: Version): string {
|
|
46
|
+
return version.join(".");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readTextIfPresent(path: string): Promise<string | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
return await readFile(path, "utf8");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether automatic `bd prime` is safe without opening the store.
|
|
60
|
+
*
|
|
61
|
+
* Beads 1.3+ migrates an embedded store in place, irreversibly, on the first command that opens
|
|
62
|
+
* it, and `--readonly` does not prevent that. `bd version` and `bd where --json` do not open the
|
|
63
|
+
* store. `.beads/.local_version` records the newest binary that opened the store through an
|
|
64
|
+
* ordinary command (but not `bd prime`), so an older or missing value means a migration may be
|
|
65
|
+
* pending. `bd migrate schema` is idempotent and updates it, clearing a stale marker.
|
|
66
|
+
*/
|
|
67
|
+
export async function checkBeadsStore(
|
|
68
|
+
directory: string,
|
|
69
|
+
runtime: GuardRuntime,
|
|
70
|
+
options: GuardOptions = {}
|
|
71
|
+
): Promise<GuardResult> {
|
|
72
|
+
let installedOutput: string;
|
|
73
|
+
try {
|
|
74
|
+
installedOutput = await runtime.bd(directory, ["version"]);
|
|
75
|
+
} catch {
|
|
76
|
+
return { kind: "unavailable" };
|
|
77
|
+
}
|
|
78
|
+
const installed = parseVersion(installedOutput);
|
|
79
|
+
if (!installed) return { kind: "unavailable" };
|
|
80
|
+
const minimum = parseVersion(MINIMUM_BD_VERSION) as Version;
|
|
81
|
+
if (compareVersions(installed, minimum) < 0) {
|
|
82
|
+
return { kind: "unsupported", installed: formatVersion(installed) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let location: unknown;
|
|
86
|
+
try {
|
|
87
|
+
location = JSON.parse(await runtime.bd(directory, ["where", "--json"]));
|
|
88
|
+
} catch {
|
|
89
|
+
// `bd where` exits non-zero without a workspace; treat every failure as no usable store.
|
|
90
|
+
return { kind: "uninitialized" };
|
|
91
|
+
}
|
|
92
|
+
const beadsPath =
|
|
93
|
+
typeof location === "object" && location !== null && "path" in location
|
|
94
|
+
? (location as { path: unknown }).path
|
|
95
|
+
: undefined;
|
|
96
|
+
if (typeof beadsPath !== "string" || !beadsPath) return { kind: "uninitialized" };
|
|
97
|
+
|
|
98
|
+
if (options.autoMigrate) return { kind: "ready" };
|
|
99
|
+
|
|
100
|
+
let marker: string | undefined;
|
|
101
|
+
try {
|
|
102
|
+
marker = await runtime.readText(join(beadsPath, ".local_version"));
|
|
103
|
+
} catch {
|
|
104
|
+
marker = undefined;
|
|
105
|
+
}
|
|
106
|
+
const store = marker === undefined ? undefined : parseVersion(marker);
|
|
107
|
+
if (!store || compareVersions(store, installed) < 0) {
|
|
108
|
+
return {
|
|
109
|
+
kind: "migration-pending",
|
|
110
|
+
installed: formatVersion(installed),
|
|
111
|
+
store: store ? formatVersion(store) : undefined,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { kind: "ready" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Build the guard runtime from a bounded `bd` runner and the local filesystem. */
|
|
118
|
+
export function createGuardRuntime(bd: GuardRuntime["bd"]): GuardRuntime {
|
|
119
|
+
return { bd, readText: readTextIfPresent };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Explain withheld context to the agent so it neither migrates the store nor guesses. */
|
|
123
|
+
export function guardNotice(result: GuardResult): string | undefined {
|
|
124
|
+
switch (result.kind) {
|
|
125
|
+
case "unsupported":
|
|
126
|
+
return `<beads-notice>
|
|
127
|
+
Beads workflow context was not loaded: the installed bd ${result.installed} is older than ${MINIMUM_BD_VERSION}, the minimum this OpenCode integration supports. Tell the user to upgrade bd before relying on Beads in this session.
|
|
128
|
+
</beads-notice>`;
|
|
129
|
+
case "migration-pending": {
|
|
130
|
+
const store = result.store ? `bd ${result.store}` : "an unknown bd version";
|
|
131
|
+
return `<beads-notice>
|
|
132
|
+
Beads workflow context was withheld: this project's Beads store was last opened by ${store}, and the installed bd ${result.installed} may apply irreversible schema migrations on first use. Do not run bd commands in this session. Tell the user to back up the .beads directory and then run \`bd migrate schema\`, or to set the opencode-beads plugin option "autoMigrate": true to allow automatic migration.
|
|
133
|
+
</beads-notice>`;
|
|
134
|
+
}
|
|
135
|
+
default:
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/guidance.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork-owned guidance for OpenCode: the shared context layer and the bounded task-agent prompt.
|
|
3
|
+
*
|
|
4
|
+
* Upstream Beads ships its task agent only for Claude Code, as an autonomous multi-task loop. This
|
|
5
|
+
* adapter deliberately diverges (one bead per invocation), so the prompt and description live here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Shown when OpenCode lists subagents; describes the one-bead worker contract. */
|
|
9
|
+
export const BEADS_TASK_AGENT_DESCRIPTION =
|
|
10
|
+
"Bounded Beads worker: analyzes the issue graph read-only, or claims, completes, and closes or blocks exactly one bead per invocation.";
|
|
11
|
+
|
|
12
|
+
const BEADS_HOST_GUIDANCE = `## CLI Safety
|
|
13
|
+
|
|
14
|
+
There is no native \`bd\` or Beads MCP tool in OpenCode. Run \`bd\` through \`bash\`.
|
|
15
|
+
Treat the injected \`bd prime\` output as the canonical workflow reference. Use \`bd --help\` to discover commands, \`bd <command> --help\` for current syntax, and \`--json\` when structured output improves reliability. Distinguish command failures from output parsing failures.
|
|
16
|
+
|
|
17
|
+
Run relevant validation before closing an issue. Do not commit, push, or run Dolt synchronization automatically; do so only when current instructions or repository policy explicitly require it.`;
|
|
18
|
+
|
|
19
|
+
const BEADS_DELEGATION_GUIDANCE = `## Delegation
|
|
20
|
+
|
|
21
|
+
Delegate multi-command Beads work and issue-graph analysis to the \`beads-task-agent\` subagent through the \`subagent\` tool; perform only single atomic operations directly.`;
|
|
22
|
+
|
|
23
|
+
/** Fork-owned system prompt for the bounded one-bead task agent. */
|
|
24
|
+
export const BEADS_TASK_AGENT_PROMPT = `## Beads Task Agent
|
|
25
|
+
|
|
26
|
+
Full \`bd prime\` workflow context is injected with each request. Use \`bd\` via \`bash\`; rerun prime if context is missing or stale.
|
|
27
|
+
|
|
28
|
+
For status or graph analysis, never claim, update, close, or modify a bead. Return the analysis and stop.
|
|
29
|
+
|
|
30
|
+
For completion, process exactly one bead. Use a caller-supplied ID; otherwise choose one highest-priority ready bead. First run \`bd show <id> --json\`; confirm it is open, actionable, and appropriate; then claim with \`bd update <id> --claim --json\`. If an assigned bead is closed, blocked, unsuitable, missing, or otherwise not actionable, report and return without mutation or substitution.
|
|
31
|
+
|
|
32
|
+
Implement and validate it. Create discoveries with \`discovered-from:<current-id>\` and a parent only when structurally obvious. Never claim or implement work created this invocation; it may be eligible later. Close or block the current bead, report discoveries, and return without seeking more work. Return concise results, not raw JSON.`;
|
|
33
|
+
|
|
34
|
+
/** Shown instead of context when `bd prime` fails, as upstream's Codex hook reports refresh failures. */
|
|
35
|
+
export function primeFailureNotice(reason: "failed" | "timed out"): string {
|
|
36
|
+
return `<beads-notice>
|
|
37
|
+
Beads workflow context could not be loaded: \`bd prime\` ${reason}. Beads may still work; run \`bd prime\` to load the workflow and see the error, and tell the user if it keeps failing.
|
|
38
|
+
</beads-notice>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Added to compaction requests so the summary keeps the Beads work in flight. */
|
|
42
|
+
export const BEADS_COMPACTION_GUIDANCE = `<beads-compaction>
|
|
43
|
+
This session tracks work in Beads (bd). In the summary, preserve every bead ID mentioned, which bead is claimed or in progress and its current status, discovered follow-up beads and how they were linked, blockers, and validation still pending. Fresh \`bd prime\` context is attached again after compaction, so do not restate the Beads workflow itself.
|
|
44
|
+
</beads-compaction>`;
|
|
45
|
+
|
|
46
|
+
/** Build the single shared guidance layer for one injected audience. */
|
|
47
|
+
export function beadsGuidance(
|
|
48
|
+
audience: "primary" | "task-agent"
|
|
49
|
+
): string {
|
|
50
|
+
const sections = [BEADS_HOST_GUIDANCE];
|
|
51
|
+
if (audience === "primary") sections.push(BEADS_DELEGATION_GUIDANCE);
|
|
52
|
+
return `<beads-guidance>\n${sections.join("\n\n")}\n</beads-guidance>`;
|
|
53
|
+
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { applyBeadsAgents, beadsAgents } from "./agents";
|
|
3
|
+
import { BEADS_COMPACTION_GUIDANCE } from "./guidance";
|
|
4
|
+
import {
|
|
5
|
+
createContextController,
|
|
6
|
+
type AgentMode,
|
|
7
|
+
type ContextDiagnostic,
|
|
8
|
+
type ContextRuntime,
|
|
9
|
+
} from "./context";
|
|
10
|
+
import { checkBeadsStore, createGuardRuntime } from "./guard";
|
|
11
|
+
import { minimizeSystemParts } from "./instructions";
|
|
12
|
+
import { runBd, runBdPrime } from "./prime";
|
|
13
|
+
|
|
14
|
+
/** The slice of the OpenCode V2 plugin context this adapter uses. */
|
|
15
|
+
export type HostContext = Pick<Plugin.Context, "location" | "options" | "event"> & {
|
|
16
|
+
agent: Pick<Plugin.Context["agent"], "get" | "transform">;
|
|
17
|
+
session: Pick<Plugin.Context["session"], "hook">;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface HostDependencies {
|
|
21
|
+
/** Override process-backed Beads calls, mainly for tests. */
|
|
22
|
+
runtime?: Partial<Pick<ContextRuntime, "guard" | "prime">>;
|
|
23
|
+
/** Override the `bd prime` runner (receives extra flags such as `--stealth`). */
|
|
24
|
+
runPrime?: (directory: string, flags: readonly string[]) => Promise<string>;
|
|
25
|
+
/** Override the bounded `bd` runner used by the store guard. */
|
|
26
|
+
bd?: (directory: string, args: readonly string[]) => Promise<string>;
|
|
27
|
+
warn?: (message: string) => void;
|
|
28
|
+
diagnosticIntervalMs?: number;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
retryMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DEFAULT_DIAGNOSTIC_INTERVAL_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
function eventSessionID(event: unknown): string | undefined {
|
|
36
|
+
if (typeof event !== "object" || event === null) return undefined;
|
|
37
|
+
const data = (event as { data?: unknown }).data;
|
|
38
|
+
if (typeof data !== "object" || data === null) return undefined;
|
|
39
|
+
const sessionID = (data as { sessionID?: unknown }).sessionID;
|
|
40
|
+
return typeof sessionID === "string" ? sessionID : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Wire Beads context into an OpenCode V2 plugin context; returns the plugin cleanup. */
|
|
44
|
+
export async function setupBeads(
|
|
45
|
+
ctx: HostContext,
|
|
46
|
+
deps: HostDependencies = {}
|
|
47
|
+
): Promise<() => void> {
|
|
48
|
+
const directory = ctx.location.directory;
|
|
49
|
+
const autoMigrate = ctx.options?.autoMigrate === true;
|
|
50
|
+
// Mirrors `bd setup claude --stealth`: prime without git operations in the session-close protocol.
|
|
51
|
+
const stealth = ctx.options?.stealth === true;
|
|
52
|
+
const now = deps.now ?? Date.now;
|
|
53
|
+
const warn = deps.warn ?? ((message: string) => console.warn(message));
|
|
54
|
+
const diagnosticIntervalMs = deps.diagnosticIntervalMs ?? DEFAULT_DIAGNOSTIC_INTERVAL_MS;
|
|
55
|
+
const diagnosticTimes = new Map<string, number>();
|
|
56
|
+
|
|
57
|
+
const guardRuntime = createGuardRuntime(deps.bd ?? ((cwd, args) => runBd(cwd, args)));
|
|
58
|
+
const runtime: ContextRuntime = {
|
|
59
|
+
guard: deps.runtime?.guard ?? ((cwd) => checkBeadsStore(cwd, guardRuntime, { autoMigrate })),
|
|
60
|
+
prime:
|
|
61
|
+
deps.runtime?.prime ??
|
|
62
|
+
((cwd) => (deps.runPrime ?? ((dir, flags) => runBdPrime(dir, {}, flags)))(cwd, stealth ? ["--stealth"] : [])),
|
|
63
|
+
async agentMode(agentID): Promise<AgentMode | undefined> {
|
|
64
|
+
const response = await ctx.agent.get({ agentID });
|
|
65
|
+
return response.data.mode;
|
|
66
|
+
},
|
|
67
|
+
diagnose(code: ContextDiagnostic, sessionID: string) {
|
|
68
|
+
const key = `${code}:${sessionID}`;
|
|
69
|
+
const previous = diagnosticTimes.get(key);
|
|
70
|
+
const current = now();
|
|
71
|
+
if (previous !== undefined && current - previous < diagnosticIntervalMs) return;
|
|
72
|
+
diagnosticTimes.set(key, current);
|
|
73
|
+
try {
|
|
74
|
+
warn(`[opencode-beads] ${code} session=${sessionID} directory=${directory}`);
|
|
75
|
+
} catch {
|
|
76
|
+
// Diagnostics never affect model requests.
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const stepsOption = ctx.options?.taskAgentSteps;
|
|
82
|
+
const agents = beadsAgents({
|
|
83
|
+
luna: ctx.options?.luna === true,
|
|
84
|
+
steps: typeof stepsOption === "number" && Number.isInteger(stepsOption) && stepsOption >= 0 ? stepsOption : undefined,
|
|
85
|
+
});
|
|
86
|
+
await ctx.agent.transform((editor) => applyBeadsAgents(editor, agents));
|
|
87
|
+
|
|
88
|
+
const idleMinutes = ctx.options?.refreshAfterIdleMinutes;
|
|
89
|
+
const controller = createContextController(runtime, directory, {
|
|
90
|
+
now: deps.now,
|
|
91
|
+
retryMs: deps.retryMs,
|
|
92
|
+
idleRefreshMs:
|
|
93
|
+
typeof idleMinutes === "number" && Number.isFinite(idleMinutes) && idleMinutes >= 0
|
|
94
|
+
? idleMinutes * 60_000
|
|
95
|
+
: undefined,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Sessions that have received Beads context; their compaction summaries should keep Beads state.
|
|
99
|
+
const beadsSessions = new Set<string>();
|
|
100
|
+
|
|
101
|
+
await ctx.session.hook("context", async (event) => {
|
|
102
|
+
const text = await controller.systemFor(event.sessionID, event.agent);
|
|
103
|
+
if (!text) return;
|
|
104
|
+
if (text.startsWith("<beads-context")) {
|
|
105
|
+
beadsSessions.add(event.sessionID);
|
|
106
|
+
// With prime attached, bd's full AGENTS.md sections only repeat it (Claude Code gets a pointer).
|
|
107
|
+
minimizeSystemParts(event.system);
|
|
108
|
+
}
|
|
109
|
+
event.system.push({ type: "text", text });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await ctx.session.hook("compaction", (event) => {
|
|
113
|
+
if (beadsSessions.has(event.sessionID)) {
|
|
114
|
+
event.system.push({ type: "text", text: BEADS_COMPACTION_GUIDANCE });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const abort = new AbortController();
|
|
119
|
+
void (async () => {
|
|
120
|
+
try {
|
|
121
|
+
for await (const event of ctx.event.subscribe({ signal: abort.signal })) {
|
|
122
|
+
const type = (event as { type?: unknown }).type;
|
|
123
|
+
const sessionID = eventSessionID(event);
|
|
124
|
+
if (!sessionID) continue;
|
|
125
|
+
if (type === "session.compaction.ended") controller.invalidate(sessionID);
|
|
126
|
+
else if (type === "session.deleted") {
|
|
127
|
+
controller.forget(sessionID);
|
|
128
|
+
beadsSessions.delete(sessionID);
|
|
129
|
+
for (const key of diagnosticTimes.keys()) {
|
|
130
|
+
if (key.endsWith(`:${sessionID}`)) diagnosticTimes.delete(key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// The stream ends on cleanup; lost events only delay a cache refresh.
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
|
|
139
|
+
return () => abort.abort();
|
|
140
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Beads instructions while prime is attached, matching Claude Code's integration.
|
|
3
|
+
*
|
|
4
|
+
* `bd setup claude` writes only a short pointer into CLAUDE.md because its hooks run `bd prime`.
|
|
5
|
+
* OpenCode projects instead get bd's full-profile AGENTS.md section (and often a Codex section),
|
|
6
|
+
* which repeats what injected prime already provides on every request. When this plugin attaches
|
|
7
|
+
* prime, those bd-managed blocks are replaced in the outgoing system text only; files are untouched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Replaces bd-managed blocks whose content duplicates injected prime. */
|
|
11
|
+
export const MINIMAL_BEADS_POINTER =
|
|
12
|
+
"Beads (bd) workflow context for this project is provided by the opencode-beads plugin in the <beads-context> block, from `bd prime`.";
|
|
13
|
+
|
|
14
|
+
// bd's full-profile integration block and its Codex setup block. Minimal-profile blocks are already
|
|
15
|
+
// equivalent to Claude Code's pointer and are left alone.
|
|
16
|
+
const DUPLICATE_BLOCKS: readonly RegExp[] = [
|
|
17
|
+
/<!-- BEGIN BEADS INTEGRATION\b[^>]*\bprofile:full\b[^>]*-->[\s\S]*?<!-- END BEADS INTEGRATION -->/g,
|
|
18
|
+
/<!-- BEGIN BEADS CODEX SETUP\b[^>]*-->[\s\S]*?<!-- END BEADS CODEX SETUP -->/g,
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Return `text` with duplicate bd blocks collapsed into one pointer, and how many were replaced. */
|
|
22
|
+
export function minimizeBeadsInstructions(text: string): { text: string; replaced: number } {
|
|
23
|
+
let replaced = 0;
|
|
24
|
+
let pointerPlaced = false;
|
|
25
|
+
let result = text;
|
|
26
|
+
for (const pattern of DUPLICATE_BLOCKS) {
|
|
27
|
+
result = result.replace(pattern, () => {
|
|
28
|
+
replaced++;
|
|
29
|
+
if (pointerPlaced) return "";
|
|
30
|
+
pointerPlaced = true;
|
|
31
|
+
return MINIMAL_BEADS_POINTER;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return { text: result, replaced };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Apply {@link minimizeBeadsInstructions} to each text system part in place. */
|
|
38
|
+
export function minimizeSystemParts(parts: Array<{ type: string; text?: string }>): number {
|
|
39
|
+
let replaced = 0;
|
|
40
|
+
for (const part of parts) {
|
|
41
|
+
if (part.type !== "text" || typeof part.text !== "string") continue;
|
|
42
|
+
const minimized = minimizeBeadsInstructions(part.text);
|
|
43
|
+
if (minimized.replaced > 0) {
|
|
44
|
+
part.text = minimized.text;
|
|
45
|
+
replaced += minimized.replaced;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return replaced;
|
|
49
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
/** OpenCode adapter for the Beads issue tracker. */
|
|
1
|
+
/** OpenCode V2 adapter for the Beads issue tracker. */
|
|
2
2
|
|
|
3
|
-
import type { Plugin } from "@opencode
|
|
4
|
-
import {
|
|
5
|
-
import { createBeadsHooks } from "./plugin-hooks";
|
|
6
|
-
import { createOpenCodeRuntime } from "./opencode-runtime";
|
|
3
|
+
import type { Plugin } from "@opencode/plugin";
|
|
4
|
+
import { setupBeads } from "./host";
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
// A plain object satisfies Plugin.define's identity contract without a runtime import.
|
|
7
|
+
const BeadsPlugin: Plugin.Plugin = {
|
|
8
|
+
id: "opencode-beads",
|
|
9
|
+
setup: (ctx) => setupBeads(ctx),
|
|
12
10
|
};
|
|
11
|
+
|
|
12
|
+
export default BeadsPlugin;
|
package/src/prime.ts
CHANGED
|
@@ -4,15 +4,15 @@ export const DEFAULT_PRIME_TIMEOUT_MS = 10_000;
|
|
|
4
4
|
export const DEFAULT_PRIME_DRAIN_TIMEOUT_MS = 1_000;
|
|
5
5
|
|
|
6
6
|
export class PrimeTimeoutError extends Error {
|
|
7
|
-
constructor(timeoutMs: number) {
|
|
8
|
-
super(
|
|
7
|
+
constructor(timeoutMs: number, command = "bd prime") {
|
|
8
|
+
super(`${command} timed out after ${timeoutMs}ms`);
|
|
9
9
|
this.name = "PrimeTimeoutError";
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export class PrimeProcessError extends Error {
|
|
14
|
-
constructor(readonly exitCode: number, readonly stderr: string) {
|
|
15
|
-
super(
|
|
14
|
+
constructor(readonly exitCode: number, readonly stderr: string, command = "bd prime") {
|
|
15
|
+
super(`${command} exited with code ${exitCode}`);
|
|
16
16
|
this.name = "PrimeProcessError";
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -34,8 +34,8 @@ export interface PrimeExecutionOptions {
|
|
|
34
34
|
scheduleDrainTimeout?: (callback: () => void, delayMs: number) => () => void;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
38
|
-
const child = Bun.spawn(["bd",
|
|
37
|
+
function spawnBd(directory: string, args: readonly string[]): PrimeProcess {
|
|
38
|
+
const child = Bun.spawn(["bd", ...args], {
|
|
39
39
|
cwd: directory,
|
|
40
40
|
stdin: "ignore",
|
|
41
41
|
stdout: "pipe",
|
|
@@ -100,14 +100,15 @@ async function drainAfterKill(
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
async function
|
|
103
|
+
async function runBdAttempt(
|
|
104
104
|
directory: string,
|
|
105
105
|
args: readonly string[],
|
|
106
106
|
options: PrimeExecutionOptions = {}
|
|
107
107
|
): Promise<string> {
|
|
108
108
|
const timeoutMs = options.timeoutMs ?? DEFAULT_PRIME_TIMEOUT_MS;
|
|
109
109
|
const drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_PRIME_DRAIN_TIMEOUT_MS;
|
|
110
|
-
const
|
|
110
|
+
const command = ["bd", ...args].join(" ");
|
|
111
|
+
const child = (options.spawn ?? spawnBd)(directory, args);
|
|
111
112
|
const stdout = new Response(child.stdout).text();
|
|
112
113
|
const stderr = new Response(child.stderr).text();
|
|
113
114
|
|
|
@@ -119,12 +120,12 @@ async function runPrimeAttempt(
|
|
|
119
120
|
const cancelTimeout = (options.scheduleTimeout ?? scheduleTimeout)(() => {
|
|
120
121
|
timedOut = true;
|
|
121
122
|
terminateProcessTree(child);
|
|
122
|
-
rejectTimeout(new PrimeTimeoutError(timeoutMs));
|
|
123
|
+
rejectTimeout(new PrimeTimeoutError(timeoutMs, command));
|
|
123
124
|
}, timeoutMs);
|
|
124
125
|
|
|
125
126
|
const completion = Promise.all([child.exited, stdout, stderr]).then(
|
|
126
127
|
([exitCode, output, errorOutput]) => {
|
|
127
|
-
if (exitCode !== 0) throw new PrimeProcessError(exitCode, errorOutput);
|
|
128
|
+
if (exitCode !== 0) throw new PrimeProcessError(exitCode, errorOutput, command);
|
|
128
129
|
return output;
|
|
129
130
|
}
|
|
130
131
|
);
|
|
@@ -143,10 +144,20 @@ async function runPrimeAttempt(
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/** Run one bounded `bd` invocation in the project directory and return its stdout. */
|
|
148
|
+
export async function runBd(
|
|
149
|
+
directory: string,
|
|
150
|
+
args: readonly string[],
|
|
151
|
+
options: PrimeExecutionOptions = {}
|
|
152
|
+
): Promise<string> {
|
|
153
|
+
return runBdAttempt(directory, args, options);
|
|
154
|
+
}
|
|
155
|
+
|
|
146
156
|
/** Load the canonical Beads workflow and persistent project memories. */
|
|
147
157
|
export async function runBdPrime(
|
|
148
158
|
directory: string,
|
|
149
|
-
options: PrimeExecutionOptions = {}
|
|
159
|
+
options: PrimeExecutionOptions = {},
|
|
160
|
+
flags: readonly string[] = []
|
|
150
161
|
): Promise<string> {
|
|
151
|
-
return
|
|
162
|
+
return runBd(directory, ["prime", ...flags], options);
|
|
152
163
|
}
|
package/src/opencode-runtime.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
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
|
-
}
|