@snarkipus/opencode-beads 1.0.2 → 1.1.0-beta.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 +41 -24
- package/assets/oc-beads/SKILL.md +149 -0
- package/assets/oc-beads/adr/0001-bd-prime-as-source-of-truth.md +40 -0
- package/assets/oc-beads/resources/AGENTS.md +43 -0
- package/assets/oc-beads/resources/ASYNC_GATES.md +63 -0
- package/assets/oc-beads/resources/BOUNDARIES.md +57 -0
- package/assets/oc-beads/resources/CHEMISTRY_PATTERNS.md +44 -0
- package/assets/oc-beads/resources/CLI_REFERENCE.md +20 -0
- package/assets/oc-beads/resources/CODE_MODE.md +78 -0
- package/assets/oc-beads/resources/DEPENDENCIES.md +82 -0
- package/assets/oc-beads/resources/INTEGRATION_PATTERNS.md +74 -0
- package/assets/oc-beads/resources/ISSUE_CREATION.md +45 -0
- package/assets/oc-beads/resources/MOLECULES.md +65 -0
- package/assets/oc-beads/resources/PATTERNS.md +43 -0
- package/assets/oc-beads/resources/RESUMABILITY.md +83 -0
- package/assets/oc-beads/resources/STATIC_DATA.md +19 -0
- package/assets/oc-beads/resources/TROUBLESHOOTING.md +49 -0
- package/assets/oc-beads/resources/WORKFLOWS.md +64 -0
- package/assets/oc-beads/resources/WORKTREES.md +19 -0
- package/package.json +5 -4
- package/src/agents.ts +2 -2
- package/src/context.ts +67 -22
- package/src/continuity.ts +380 -0
- package/src/guard.ts +44 -11
- package/src/guidance.ts +65 -15
- package/src/host.ts +323 -42
- package/src/instructions.ts +11 -8
- package/src/plugin.ts +2 -1
- package/src/prime.ts +15 -4
- package/src/workflows.ts +131 -0
package/src/host.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { applyBeadsSkill, createBeadsCommands, loadBeadsSkill, type SkillLoadDependencies } from "./workflows";
|
|
3
|
+
import { applyBeadsAgents, beadsAgents, BEADS_TASK_AGENTS } from "./agents";
|
|
4
|
+
import { BEADS_COMPACTION_GUIDANCE, UNKNOWN_CAPABILITIES, type RequestCapabilities } from "./guidance";
|
|
4
5
|
import {
|
|
5
6
|
createContextController,
|
|
6
7
|
type AgentMode,
|
|
@@ -10,11 +11,16 @@ import {
|
|
|
10
11
|
import { checkBeadsStore, createGuardRuntime } from "./guard";
|
|
11
12
|
import { minimizeSystemParts } from "./instructions";
|
|
12
13
|
import { runBd, runBdPrime } from "./prime";
|
|
14
|
+
import { createContinuityTools, type ContinuityDependencies, type ContinuityResult } from "./continuity";
|
|
13
15
|
|
|
14
16
|
/** The slice of the OpenCode V2 plugin context this adapter uses. */
|
|
15
|
-
export type HostContext = Pick<Plugin.Context, "location" | "options" | "event"> & {
|
|
17
|
+
export type HostContext = Pick<Plugin.Context, "app" | "location" | "options" | "event"> & {
|
|
16
18
|
agent: Pick<Plugin.Context["agent"], "get" | "transform">;
|
|
17
|
-
session: Pick<Plugin.Context["session"], "hook"
|
|
19
|
+
session: Pick<Plugin.Context["session"], "hook"> & Partial<Pick<Plugin.Context["session"], "get" | "prompt">>;
|
|
20
|
+
tool?: Pick<Plugin.Context["tool"], "hook" | "transform">;
|
|
21
|
+
permission?: Pick<Plugin.Context["permission"], "assert">;
|
|
22
|
+
command?: Pick<Plugin.Context["command"], "list" | "transform">;
|
|
23
|
+
skill?: Pick<Plugin.Context["skill"], "list" | "transform">;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export interface HostDependencies {
|
|
@@ -28,9 +34,25 @@ export interface HostDependencies {
|
|
|
28
34
|
diagnosticIntervalMs?: number;
|
|
29
35
|
now?: () => number;
|
|
30
36
|
retryMs?: number;
|
|
37
|
+
/** Enable the accepted code-mode registration; public beta entrypoint supplies true. Tests may omit it. */
|
|
38
|
+
continuityQualified?: boolean;
|
|
39
|
+
/** Test overrides for the accepted adapter dependencies; public setup uses production defaults. */
|
|
40
|
+
continuity?: ContinuityDependencies;
|
|
41
|
+
/** Override package reads only; no user-facing activation or installation option. */
|
|
42
|
+
workflowAssets?: SkillLoadDependencies;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
const DEFAULT_DIAGNOSTIC_INTERVAL_MS = 60_000;
|
|
46
|
+
const SUPPORTED_OPENCODE_RANGE = ">=2.1.1 <2.2";
|
|
47
|
+
|
|
48
|
+
function isSupportedOpenCodeVersion(version: string): boolean {
|
|
49
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?$/.exec(version);
|
|
50
|
+
if (!match) return false;
|
|
51
|
+
const major = Number(match[1]);
|
|
52
|
+
const minor = Number(match[2]);
|
|
53
|
+
const patch = Number(match[3]);
|
|
54
|
+
return major === 2 && minor === 1 && patch >= 1;
|
|
55
|
+
}
|
|
34
56
|
|
|
35
57
|
function eventSessionID(event: unknown): string | undefined {
|
|
36
58
|
if (typeof event !== "object" || event === null) return undefined;
|
|
@@ -44,7 +66,7 @@ function eventSessionID(event: unknown): string | undefined {
|
|
|
44
66
|
export async function setupBeads(
|
|
45
67
|
ctx: HostContext,
|
|
46
68
|
deps: HostDependencies = {}
|
|
47
|
-
): Promise<() => void
|
|
69
|
+
): Promise<() => Promise<void>> {
|
|
48
70
|
const directory = ctx.location.directory;
|
|
49
71
|
const autoMigrate = ctx.options?.autoMigrate === true;
|
|
50
72
|
// `--full` defeats bd's MCP detection, which reads Claude Code's settings and would otherwise
|
|
@@ -55,13 +77,27 @@ export async function setupBeads(
|
|
|
55
77
|
const warn = deps.warn ?? ((message: string) => console.warn(message));
|
|
56
78
|
const diagnosticIntervalMs = deps.diagnosticIntervalMs ?? DEFAULT_DIAGNOSTIC_INTERVAL_MS;
|
|
57
79
|
const diagnosticTimes = new Map<string, number>();
|
|
80
|
+
const abort = new AbortController();
|
|
81
|
+
const registrations: Array<{ dispose(): Promise<void> }> = [];
|
|
82
|
+
|
|
83
|
+
const hostVersion = typeof ctx.app?.version === "string" ? ctx.app.version : "unknown";
|
|
84
|
+
if (!isSupportedOpenCodeVersion(hostVersion)) {
|
|
85
|
+
try {
|
|
86
|
+
warn(
|
|
87
|
+
`[opencode-beads] unsupported-host OpenCode ${hostVersion}; supported beta/next range is ${SUPPORTED_OPENCODE_RANGE}. No Beads agents or hooks were registered.`
|
|
88
|
+
);
|
|
89
|
+
} catch {
|
|
90
|
+
// Host-version diagnostics never affect plugin loading.
|
|
91
|
+
}
|
|
92
|
+
return async () => {};
|
|
93
|
+
}
|
|
58
94
|
|
|
59
|
-
const guardRuntime = createGuardRuntime(deps.bd ?? ((cwd, args) => runBd(cwd, args)));
|
|
60
95
|
const runtime: ContextRuntime = {
|
|
61
|
-
guard: deps.runtime?.guard ?? ((cwd) => checkBeadsStore(cwd,
|
|
96
|
+
guard: deps.runtime?.guard ?? ((cwd, signal) => checkBeadsStore(cwd,
|
|
97
|
+
createGuardRuntime(deps.bd ?? ((dir, args) => runBd(dir, args, { signal }))), { autoMigrate })),
|
|
62
98
|
prime:
|
|
63
99
|
deps.runtime?.prime ??
|
|
64
|
-
((cwd) =>
|
|
100
|
+
((cwd, signal) => deps.runPrime ? deps.runPrime(cwd, primeFlags) : runBdPrime(cwd, { signal }, primeFlags)),
|
|
65
101
|
async agentMode(agentID): Promise<AgentMode | undefined> {
|
|
66
102
|
const response = await ctx.agent.get({ agentID });
|
|
67
103
|
return response.data.mode;
|
|
@@ -85,7 +121,6 @@ export async function setupBeads(
|
|
|
85
121
|
luna: ctx.options?.luna === true,
|
|
86
122
|
steps: typeof stepsOption === "number" && Number.isInteger(stepsOption) && stepsOption >= 0 ? stepsOption : undefined,
|
|
87
123
|
});
|
|
88
|
-
await ctx.agent.transform((editor) => applyBeadsAgents(editor, agents));
|
|
89
124
|
|
|
90
125
|
const idleMinutes = ctx.options?.refreshAfterIdleMinutes;
|
|
91
126
|
const controller = createContextController(runtime, directory, {
|
|
@@ -98,45 +133,291 @@ export async function setupBeads(
|
|
|
98
133
|
});
|
|
99
134
|
|
|
100
135
|
// Sessions that have received Beads context; their compaction summaries should keep Beads state.
|
|
101
|
-
const beadsSessions = new Set<string
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
const beadsSessions = new Map<string, Set<string>>();
|
|
137
|
+
const recoveryCues = new Map<string, string>();
|
|
138
|
+
const references = new Map<string, Set<string>>();
|
|
139
|
+
const gaps = new Map<string, Set<string>>();
|
|
140
|
+
const requestCapabilities = new Map<string, { agent: string; capabilities: RequestCapabilities }>();
|
|
141
|
+
let typedRegistration = deps.continuityQualified === true ? "unavailable: host tool/permission/session interface missing" : "disabled: registration not requested";
|
|
142
|
+
|
|
143
|
+
/** Retain distinct unresolved outcomes without a read, success or lifecycle cue erasing them. */
|
|
144
|
+
function recordGap(sessionID: string, detail: string): void {
|
|
145
|
+
const known = gaps.get(sessionID) ?? new Set<string>();
|
|
146
|
+
if (known.size < 32) known.add(detail);
|
|
147
|
+
else if (!known.has(detail)) known.add("Additional persistence gaps exceed the retained detail limit; earlier unresolved details remain. Inspect tool outcomes before further writes.");
|
|
148
|
+
gaps.set(sessionID, known);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Render bounded persistence state independently of the next-request recovery cue. */
|
|
152
|
+
function gapFor(sessionID: string): string | undefined {
|
|
153
|
+
return [...(gaps.get(sessionID) ?? [])].join("\n") || undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Drop every location-bound reference together before another request can reuse it. */
|
|
157
|
+
function forgetSession(sessionID: string): void {
|
|
158
|
+
controller.forget(sessionID);
|
|
159
|
+
beadsSessions.delete(sessionID);
|
|
160
|
+
references.delete(sessionID);
|
|
161
|
+
recoveryCues.delete(sessionID);
|
|
162
|
+
gaps.delete(sessionID);
|
|
163
|
+
requestCapabilities.delete(sessionID);
|
|
164
|
+
for (const key of diagnosticTimes.keys()) {
|
|
165
|
+
if (key.endsWith(`:${sessionID}`)) diagnosticTimes.delete(key);
|
|
110
166
|
}
|
|
111
|
-
|
|
112
|
-
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let observation: Promise<void> | undefined;
|
|
170
|
+
let cleanupPromise: Promise<void> | undefined;
|
|
171
|
+
/** Abort immediately; callers can await all scoped registration/observer disposal. */
|
|
172
|
+
function cleanup(): Promise<void> {
|
|
173
|
+
if (cleanupPromise) return cleanupPromise;
|
|
174
|
+
abort.abort();
|
|
175
|
+
controller.dispose();
|
|
176
|
+
beadsSessions.clear();
|
|
177
|
+
recoveryCues.clear();
|
|
178
|
+
references.clear();
|
|
179
|
+
gaps.clear();
|
|
180
|
+
requestCapabilities.clear();
|
|
181
|
+
diagnosticTimes.clear();
|
|
182
|
+
const pending = registrations.splice(0).reverse().map((registration) => {
|
|
183
|
+
try { return registration.dispose(); } catch (error) { return Promise.reject(error); }
|
|
184
|
+
});
|
|
185
|
+
cleanupPromise = Promise.allSettled([...pending, ...(observation ? [observation] : [])]).then((results) => {
|
|
186
|
+
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
187
|
+
if (failures.length) {
|
|
188
|
+
try { warn("[opencode-beads] cleanup-failed: owned registration disposal failed"); } catch { /* Preserve the actual failure. */ }
|
|
189
|
+
throw new AggregateError(failures.map((result) => result.reason), "Beads cleanup failed");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
return cleanupPromise;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
registrations.push(await ctx.agent.transform((editor) => {
|
|
197
|
+
if (!abort.signal.aborted) applyBeadsAgents(editor, agents);
|
|
198
|
+
}));
|
|
113
199
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
200
|
+
// Diagnostics share the host's bounded reporting state; no status service or store reads.
|
|
201
|
+
function workflowDiagnostic(message: string): void {
|
|
202
|
+
if (abort.signal.aborted || diagnosticTimes.has(message)) return;
|
|
203
|
+
diagnosticTimes.set(message, now());
|
|
204
|
+
try { warn(`[opencode-beads] ${message}`); } catch { /* Diagnostics never break loading. */ }
|
|
117
205
|
}
|
|
118
|
-
});
|
|
119
206
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
207
|
+
let skillFailure: string | undefined;
|
|
208
|
+
if (ctx.skill) {
|
|
209
|
+
let skill;
|
|
210
|
+
try { skill = await loadBeadsSkill(deps.workflowAssets); }
|
|
211
|
+
catch (error) {
|
|
212
|
+
skillFailure = error instanceof Error ? error.message : "skill-unreachable";
|
|
213
|
+
workflowDiagnostic(skillFailure);
|
|
214
|
+
}
|
|
215
|
+
if (skill) {
|
|
216
|
+
const packagedSkill = skill;
|
|
217
|
+
registrations.push(await ctx.skill.transform((editor) => {
|
|
218
|
+
if (!abort.signal.aborted) applyBeadsSkill(editor, packagedSkill, workflowDiagnostic);
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Native package transforms precede config/file command transforms. Competing
|
|
224
|
+
// plugins use the same ordinary ordering; initial lookup is diagnostic only.
|
|
225
|
+
if (ctx.command && ctx.session.prompt) {
|
|
226
|
+
let commandRegistration = "pending";
|
|
227
|
+
const definitions = createBeadsCommands({ prompt: ctx.session.prompt }, async (sessionID) => {
|
|
228
|
+
let context: string = "uncertain";
|
|
229
|
+
let locationKnown = false;
|
|
230
|
+
if (ctx.session.get) {
|
|
231
|
+
try {
|
|
232
|
+
const info = await ctx.session.get({ sessionID: sessionID as Parameters<NonNullable<typeof ctx.session.get>>[0]["sessionID"] });
|
|
233
|
+
if (info.location.directory !== directory) {
|
|
234
|
+
forgetSession(sessionID);
|
|
235
|
+
context = "withheld: session location changed";
|
|
236
|
+
} else {
|
|
237
|
+
locationKnown = true;
|
|
238
|
+
const observedAgent = requestCapabilities.get(sessionID)?.agent;
|
|
239
|
+
const audience = observedAgent && BEADS_TASK_AGENTS.has(observedAgent) ? "task-agent" : "primary";
|
|
240
|
+
const cached = controller.statusFor(sessionID, audience);
|
|
241
|
+
context = cached === "loading" ? "uncertain: context refresh in progress" : cached ?? "missing: not loaded";
|
|
242
|
+
}
|
|
243
|
+
} catch { forgetSession(sessionID); context = "uncertain: session lookup failed"; }
|
|
244
|
+
}
|
|
245
|
+
let guard: unknown = { kind: "withheld", reason: "session location unconfirmed" };
|
|
246
|
+
if (locationKnown && !abort.signal.aborted) {
|
|
247
|
+
try { guard = await runtime.guard(directory, abort.signal); }
|
|
248
|
+
catch { guard = { kind: "probe-failed" }; }
|
|
134
249
|
}
|
|
250
|
+
let effectiveCommands: Array<{ name: string; description?: string }> | undefined;
|
|
251
|
+
let effectiveSkills: string[] | string;
|
|
252
|
+
try { effectiveCommands = (await ctx.command!.list()).data.map(({ name, description }) => ({ name, description })); }
|
|
253
|
+
catch { /* Absence of lookup evidence is unknown, not an empty registry. */ }
|
|
254
|
+
try { effectiveSkills = ctx.skill ? (await ctx.skill.list()).data.map((entry) => String(entry.id)) : "unavailable"; }
|
|
255
|
+
catch { effectiveSkills = "unavailable: registry lookup failed"; }
|
|
256
|
+
return JSON.stringify({
|
|
257
|
+
host: hostVersion, admission: SUPPORTED_OPENCODE_RANGE, liveQualification: "not measured by status; see release acceptance evidence",
|
|
258
|
+
commands: {
|
|
259
|
+
registration: commandRegistration,
|
|
260
|
+
lookup: effectiveCommands ? "available" : "failed",
|
|
261
|
+
effective: effectiveCommands ?? "unknown: registry lookup failed",
|
|
262
|
+
mismatches: effectiveCommands && definitions.flatMap((expected) => {
|
|
263
|
+
const effective = effectiveCommands.find((entry) => entry.name === expected.name);
|
|
264
|
+
return effective && effective.description !== expected.description
|
|
265
|
+
? [{ name: expected.name, expectedDescription: expected.description, effectiveDescription: effective.description }]
|
|
266
|
+
: [];
|
|
267
|
+
}),
|
|
268
|
+
missing: effectiveCommands && definitions.filter((expected) => !effectiveCommands.some((entry) => entry.name === expected.name)).map((entry) => entry.name),
|
|
269
|
+
ownership: "unavailable: public owner identity is not exposed; matching names/descriptions do not prove ownership; identical-description overrides cannot be detected",
|
|
270
|
+
},
|
|
271
|
+
skill: { failure: skillFailure, effective: effectiveSkills },
|
|
272
|
+
typedContinuity: deps.continuityQualified === true ? "enabled: qualified beta registration" : "disabled: registration not requested",
|
|
273
|
+
typedRegistration,
|
|
274
|
+
requestCapability: requestCapabilities.get(sessionID) ?? "unknown: no actual request observation",
|
|
275
|
+
leafAvailability: "unknown until native catalog discovery and per-operation permission checks; execute visibility alone does not prove leaf availability",
|
|
276
|
+
context, guard, persistence: gapFor(sessionID) ?? "uncertain: no final-write guarantee",
|
|
277
|
+
});
|
|
278
|
+
}, deps.workflowAssets?.skillPath, abort.signal);
|
|
279
|
+
let existing: string[];
|
|
280
|
+
try { existing = (await ctx.command.list()).data.map((entry) => entry.name); }
|
|
281
|
+
catch { existing = []; workflowDiagnostic("command-registry-unavailable: effective ownership unknown"); }
|
|
282
|
+
const names = definitions.map((entry) => entry.name);
|
|
283
|
+
const collisions = existing.filter((name) => names.includes(name));
|
|
284
|
+
if (collisions.length) workflowDiagnostic(`command-collision: ${collisions.join(", ")}; earlier entries follow native precedence and may be replaced by this package`);
|
|
285
|
+
try {
|
|
286
|
+
registrations.push(await ctx.command.transform((editor) => {
|
|
287
|
+
if (abort.signal.aborted) return;
|
|
288
|
+
for (const definition of definitions) editor.add(definition);
|
|
289
|
+
}));
|
|
290
|
+
commandRegistration = "registered";
|
|
291
|
+
} catch (error) {
|
|
292
|
+
commandRegistration = "failed";
|
|
293
|
+
workflowDiagnostic("command-registration-failed: native transform failed");
|
|
294
|
+
throw error;
|
|
135
295
|
}
|
|
136
|
-
} catch {
|
|
137
|
-
// The stream ends on cleanup; lost events only delay a cache refresh.
|
|
138
296
|
}
|
|
139
|
-
})();
|
|
140
297
|
|
|
141
|
-
|
|
298
|
+
function rememberResult(sessionID: string, result: ContinuityResult): void {
|
|
299
|
+
if (abort.signal.aborted) return;
|
|
300
|
+
const write = ["claim", "note", "update", "close", "create", "remember"].includes(result.operation);
|
|
301
|
+
// Full prime can depend on issue state. 7.1 does not establish safe narrowing.
|
|
302
|
+
if (write && result.status !== "not-claimed") controller.invalidateAll();
|
|
303
|
+
const values = references.get(sessionID) ?? new Set<string>();
|
|
304
|
+
const memory = ["remember", "recall", "memories"].includes(result.operation);
|
|
305
|
+
if (["acknowledged", "found", "landed-uncommitted"].includes(result.status)) {
|
|
306
|
+
if (result.reference) values.add(`${memory ? "memory" : "task"}:${result.reference}`);
|
|
307
|
+
if (result.operation === "get") for (const id of Object.keys(result.issues ?? {})) values.add(`task:${id}`);
|
|
308
|
+
if (result.operation === "find") for (const row of result.records ?? []) values.add(`task:${row.id}`);
|
|
309
|
+
}
|
|
310
|
+
while (values.size > 32) values.delete(values.values().next().value!);
|
|
311
|
+
if (values.size) references.set(sessionID, values);
|
|
312
|
+
if (["uncertain", "failed", "landed-uncommitted"].includes(result.status) || result.complete === false) {
|
|
313
|
+
const detail = `${result.operation} ${JSON.stringify(result.target)}${result.actor ? ` actor=${result.actor}` : ""}: ${result.status}; ${result.detail ?? (result.complete === false ? "partial result; inspect failed IDs/completeness" : "commit not confirmed")}`;
|
|
314
|
+
if (write) recordGap(sessionID, detail);
|
|
315
|
+
else recoveryCues.set(sessionID, `Inspect current ${memory ? "memory" : "task"} state before relying on this outcome: ${detail}. Do not retry an uncertain write blindly.`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The public beta entrypoint enables this path; registration still requires native interfaces.
|
|
320
|
+
if (deps.continuityQualified === true && ctx.tool && ctx.permission && ctx.session.get) {
|
|
321
|
+
registrations.push(await ctx.tool.transform((editor) => {
|
|
322
|
+
if (abort.signal.aborted) return;
|
|
323
|
+
editor.namespace({ name: "beads", description: "Permissioned Beads daily-loop reads and writes. Search this namespace for current signatures; inspect outcome and completeness before dependent work." });
|
|
324
|
+
for (const tool of createContinuityTools({ location: ctx.location, permission: ctx.permission!, session: { get: ctx.session.get! } }, {
|
|
325
|
+
...deps.continuity, signal: abort.signal,
|
|
326
|
+
acknowledged(sessionID, result) {
|
|
327
|
+
rememberResult(sessionID, result);
|
|
328
|
+
deps.continuity?.acknowledged?.(sessionID, result);
|
|
329
|
+
},
|
|
330
|
+
})) editor.add(tool);
|
|
331
|
+
typedRegistration = "registered: beads namespace, code-mode-only";
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
registrations.push(await ctx.session.hook("context", async (event) => {
|
|
336
|
+
if (abort.signal.aborted) return;
|
|
337
|
+
if (ctx.session.get) {
|
|
338
|
+
try {
|
|
339
|
+
const info = await ctx.session.get({ sessionID: event.sessionID });
|
|
340
|
+
if (info.location.directory !== directory) {
|
|
341
|
+
forgetSession(event.sessionID);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
forgetSession(event.sessionID);
|
|
346
|
+
if (!abort.signal.aborted) event.system.push({ type: "text", text: "<beads-notice>Beads context withheld: current session location could not be established. Do not bypass this hold with manual store access.</beads-notice>" });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const knownTools = event.tools && typeof event.tools === "object";
|
|
351
|
+
const capabilities: RequestCapabilities = knownTools ? {
|
|
352
|
+
execute: Object.hasOwn(event.tools, "execute"), shell: Object.hasOwn(event.tools, "shell"),
|
|
353
|
+
typed: typedRegistration.startsWith("registered") && Object.hasOwn(event.tools, "execute") ? "registered-execute-visible" : "unavailable",
|
|
354
|
+
} : { ...UNKNOWN_CAPABILITIES };
|
|
355
|
+
requestCapabilities.set(event.sessionID, { agent: event.agent, capabilities });
|
|
356
|
+
const text = await controller.systemFor(event.sessionID, event.agent, capabilities);
|
|
357
|
+
if (!text || abort.signal.aborted) return;
|
|
358
|
+
if (text.startsWith("<beads-context")) {
|
|
359
|
+
const eligible = beadsSessions.get(event.sessionID) ?? new Set<string>();
|
|
360
|
+
eligible.add(event.agent);
|
|
361
|
+
beadsSessions.set(event.sessionID, eligible);
|
|
362
|
+
// Reduce only complete bodies already supplied here; marker ownership alone is insufficient.
|
|
363
|
+
minimizeSystemParts(event.system, text);
|
|
364
|
+
}
|
|
365
|
+
event.system.push({ type: "text", text });
|
|
366
|
+
const cue = recoveryCues.get(event.sessionID);
|
|
367
|
+
const gap = gapFor(event.sessionID);
|
|
368
|
+
if (cue || gap) {
|
|
369
|
+
const recovery = [cue, gap && `Known persistence gaps: inspect durable state before another write; do not blindly retry.\n${gap}`].filter(Boolean).join("\n");
|
|
370
|
+
event.system.push({ type: "text", text: `<beads-recovery>${recovery}</beads-recovery>` });
|
|
371
|
+
recoveryCues.delete(event.sessionID);
|
|
372
|
+
}
|
|
373
|
+
}));
|
|
374
|
+
|
|
375
|
+
registrations.push(await ctx.session.hook("compaction", (event) => {
|
|
376
|
+
if (!abort.signal.aborted && beadsSessions.get(event.sessionID)?.has(event.agent)) {
|
|
377
|
+
event.system.push({ type: "text", text: BEADS_COMPACTION_GUIDANCE });
|
|
378
|
+
const known = references.get(event.sessionID);
|
|
379
|
+
const gap = gapFor(event.sessionID);
|
|
380
|
+
if (known?.size || gap) event.system.push({ type: "text", text: `<beads-references>${JSON.stringify({ references: [...(known ?? [])], gap: gap ?? "No final-write guarantee; only earlier acknowledgements are known." })}</beads-references>` });
|
|
381
|
+
}
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
if (ctx.tool) registrations.push(await ctx.tool.hook("execute.after", (event) => {
|
|
385
|
+
// Typed inner calls acknowledge in their executor. The outer code-mode result adds nothing.
|
|
386
|
+
if (abort.signal.aborted || event.tool === "execute" || event.tool.startsWith("beads_") || !beadsSessions.has(event.sessionID)) return;
|
|
387
|
+
if (event.tool === "subagent") {
|
|
388
|
+
controller.invalidate(event.sessionID);
|
|
389
|
+
recoveryCues.set(event.sessionID, "A child returned. Reconcile its scope, attempts, validation, durable references and incomplete work before proceeding within the existing authorization.");
|
|
390
|
+
}
|
|
391
|
+
}));
|
|
392
|
+
|
|
393
|
+
observation = (async () => {
|
|
394
|
+
try {
|
|
395
|
+
for await (const event of ctx.event.subscribe({ signal: abort.signal })) {
|
|
396
|
+
if (abort.signal.aborted) break;
|
|
397
|
+
const type = (event as { type?: unknown }).type;
|
|
398
|
+
const sessionID = eventSessionID(event);
|
|
399
|
+
if (!sessionID) continue;
|
|
400
|
+
if (type === "session.deleted" || type === "session.moved" || type === "session.forked") {
|
|
401
|
+
forgetSession(sessionID);
|
|
402
|
+
} else if (beadsSessions.has(sessionID) && ["session.compaction.started", "session.compaction.ended", "session.compaction.failed",
|
|
403
|
+
"session.execution.started", "session.execution.interrupted", "session.execution.failed", "session.model.selected", "session.agent.selected"].includes(String(type))) {
|
|
404
|
+
controller.invalidate(sessionID);
|
|
405
|
+
recoveryCues.set(sessionID, "Recovery requires current task state and relevant memory: re-read attempts, decisions, blockers and the next authorized action. Cached absence is not proof of absence; preserve any guard hold.");
|
|
406
|
+
if (type === "session.execution.interrupted" || type === "session.execution.failed" || type === "session.compaction.failed") {
|
|
407
|
+
recordGap(sessionID, "Execution or compaction stopped; no final checkpoint is guaranteed. Recover from earlier acknowledged records.");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// The stream ends on cleanup; lost events only delay a cache refresh.
|
|
413
|
+
}
|
|
414
|
+
})();
|
|
415
|
+
|
|
416
|
+
return cleanup;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
// Setup failures must not leave earlier registrations alive. Keep the setup error
|
|
419
|
+
// as the primary failure; cleanup reports its own failure independently.
|
|
420
|
+
await cleanup().catch(() => {});
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
142
423
|
}
|
package/src/instructions.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `bd setup claude` writes only a short pointer into CLAUDE.md because its hooks run `bd prime`.
|
|
5
5
|
* OpenCode projects instead get bd's full-profile AGENTS.md section (and often a Codex section),
|
|
6
|
-
* which
|
|
7
|
-
*
|
|
6
|
+
* which can repeat injected prime. Only demonstrably duplicated complete bodies are replaced in
|
|
7
|
+
* outgoing system text; customized blocks and files are untouched.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/** Replaces bd-managed blocks whose content duplicates injected prime. */
|
|
@@ -14,17 +14,20 @@ export const MINIMAL_BEADS_POINTER =
|
|
|
14
14
|
// bd's full-profile integration block and its Codex setup block. Minimal-profile blocks are already
|
|
15
15
|
// equivalent to Claude Code's pointer and are left alone.
|
|
16
16
|
const DUPLICATE_BLOCKS: readonly RegExp[] = [
|
|
17
|
-
/<!-- BEGIN BEADS INTEGRATION\b[^>]*\bprofile:full\b[^>]*-->[\s\S]
|
|
18
|
-
/<!-- BEGIN BEADS CODEX SETUP\b[^>]*-->[\s\S]
|
|
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
19
|
];
|
|
20
20
|
|
|
21
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 } {
|
|
22
|
+
export function minimizeBeadsInstructions(text: string, suppliedContext = ""): { text: string; replaced: number } {
|
|
23
23
|
let replaced = 0;
|
|
24
24
|
let pointerPlaced = false;
|
|
25
25
|
let result = text;
|
|
26
26
|
for (const pattern of DUPLICATE_BLOCKS) {
|
|
27
|
-
result = result.replace(pattern, () => {
|
|
27
|
+
result = result.replace(pattern, (block: string, body: string) => {
|
|
28
|
+
// Markers prove ownership, not duplication. Keep customized policy unless the complete
|
|
29
|
+
// body is already present in the context actually attached to this request.
|
|
30
|
+
if (!body.trim() || !suppliedContext.includes(body.trim())) return block;
|
|
28
31
|
replaced++;
|
|
29
32
|
if (pointerPlaced) return "";
|
|
30
33
|
pointerPlaced = true;
|
|
@@ -35,11 +38,11 @@ export function minimizeBeadsInstructions(text: string): { text: string; replace
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/** Apply {@link minimizeBeadsInstructions} to each text system part in place. */
|
|
38
|
-
export function minimizeSystemParts(parts: Array<{ type: string; text?: string }
|
|
41
|
+
export function minimizeSystemParts(parts: Array<{ type: string; text?: string }>, suppliedContext = ""): number {
|
|
39
42
|
let replaced = 0;
|
|
40
43
|
for (const part of parts) {
|
|
41
44
|
if (part.type !== "text" || typeof part.text !== "string") continue;
|
|
42
|
-
const minimized = minimizeBeadsInstructions(part.text);
|
|
45
|
+
const minimized = minimizeBeadsInstructions(part.text, suppliedContext);
|
|
43
46
|
if (minimized.replaced > 0) {
|
|
44
47
|
part.text = minimized.text;
|
|
45
48
|
replaced += minimized.replaced;
|
package/src/plugin.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import type { Plugin } from "@opencode/plugin";
|
|
4
4
|
import { setupBeads } from "./host";
|
|
5
5
|
|
|
6
|
+
// The prerelease artifact opts into the accepted code-mode registration.
|
|
6
7
|
// A plain object satisfies Plugin.define's identity contract without a runtime import.
|
|
7
8
|
const BeadsPlugin: Plugin.Plugin = {
|
|
8
9
|
id: "opencode-beads",
|
|
9
|
-
setup: (ctx) => setupBeads(ctx),
|
|
10
|
+
setup: (ctx) => setupBeads(ctx, { continuityQualified: true }),
|
|
10
11
|
};
|
|
11
12
|
|
|
12
13
|
export default BeadsPlugin;
|
package/src/prime.ts
CHANGED
|
@@ -11,7 +11,7 @@ export class PrimeTimeoutError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export class PrimeProcessError extends Error {
|
|
14
|
-
constructor(readonly exitCode: number, readonly stderr: string, command = "bd prime") {
|
|
14
|
+
constructor(readonly exitCode: number, readonly stderr: string, command = "bd prime", readonly stdout = "") {
|
|
15
15
|
super(`${command} exited with code ${exitCode}`);
|
|
16
16
|
this.name = "PrimeProcessError";
|
|
17
17
|
}
|
|
@@ -27,6 +27,7 @@ export interface PrimeProcess {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface PrimeExecutionOptions {
|
|
30
|
+
signal?: AbortSignal;
|
|
30
31
|
timeoutMs?: number;
|
|
31
32
|
drainTimeoutMs?: number;
|
|
32
33
|
spawn?: (directory: string, args: readonly string[]) => PrimeProcess;
|
|
@@ -108,12 +109,14 @@ async function runBdAttempt(
|
|
|
108
109
|
const timeoutMs = options.timeoutMs ?? DEFAULT_PRIME_TIMEOUT_MS;
|
|
109
110
|
const drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_PRIME_DRAIN_TIMEOUT_MS;
|
|
110
111
|
const command = ["bd", ...args].join(" ");
|
|
112
|
+
options.signal?.throwIfAborted();
|
|
111
113
|
const child = (options.spawn ?? spawnBd)(directory, args);
|
|
112
114
|
const stdout = new Response(child.stdout).text();
|
|
113
115
|
const stderr = new Response(child.stderr).text();
|
|
114
116
|
|
|
115
117
|
let timedOut = false;
|
|
116
|
-
let
|
|
118
|
+
let aborted = false;
|
|
119
|
+
let rejectTimeout: (error: unknown) => void = () => {};
|
|
117
120
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
118
121
|
rejectTimeout = reject;
|
|
119
122
|
});
|
|
@@ -122,10 +125,17 @@ async function runBdAttempt(
|
|
|
122
125
|
terminateProcessTree(child);
|
|
123
126
|
rejectTimeout(new PrimeTimeoutError(timeoutMs, command));
|
|
124
127
|
}, timeoutMs);
|
|
128
|
+
const onAbort = () => {
|
|
129
|
+
aborted = true;
|
|
130
|
+
terminateProcessTree(child);
|
|
131
|
+
rejectTimeout(options.signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
132
|
+
};
|
|
133
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
134
|
+
if (options.signal?.aborted) onAbort();
|
|
125
135
|
|
|
126
136
|
const completion = Promise.all([child.exited, stdout, stderr]).then(
|
|
127
137
|
([exitCode, output, errorOutput]) => {
|
|
128
|
-
if (exitCode !== 0) throw new PrimeProcessError(exitCode, errorOutput, command);
|
|
138
|
+
if (exitCode !== 0) throw new PrimeProcessError(exitCode, errorOutput, command, output);
|
|
129
139
|
return output;
|
|
130
140
|
}
|
|
131
141
|
);
|
|
@@ -134,7 +144,8 @@ async function runBdAttempt(
|
|
|
134
144
|
return await Promise.race([completion, timeout]);
|
|
135
145
|
} finally {
|
|
136
146
|
cancelTimeout();
|
|
137
|
-
|
|
147
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
148
|
+
if (timedOut || aborted) {
|
|
138
149
|
await drainAfterKill(
|
|
139
150
|
completion.catch(() => undefined),
|
|
140
151
|
drainTimeoutMs,
|