@fro.bot/systematic 2.7.1 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -310,6 +310,37 @@ function registerSkillsPaths(config, skillsDir) {
310
310
  }
311
311
  }
312
312
 
313
+ // src/lib/plugin-singleton.ts
314
+ var SINGLETON_KEY = Symbol.for("systematic.singleton.v1");
315
+ async function plugInOnce({
316
+ doInit,
317
+ onDuplicate,
318
+ pid
319
+ }) {
320
+ const currentPid = pid ?? process.pid;
321
+ const g = globalThis;
322
+ const existing = g[SINGLETON_KEY];
323
+ if (existing && existing.pid === currentPid) {
324
+ if (!existing.warned) {
325
+ existing.warned = true;
326
+ try {
327
+ onDuplicate?.(currentPid);
328
+ } catch {}
329
+ }
330
+ await existing.hooksPromise;
331
+ return { isFirst: false, hooks: {} };
332
+ }
333
+ const hooksPromise = doInit();
334
+ g[SINGLETON_KEY] = {
335
+ pid: currentPid,
336
+ loadedAt: Date.now(),
337
+ hooksPromise,
338
+ warned: false
339
+ };
340
+ const hooks = await hooksPromise;
341
+ return { isFirst: true, hooks };
342
+ }
343
+
313
344
  // src/lib/skill-tool.ts
314
345
  import fs2 from "fs";
315
346
  import path3 from "path";
@@ -493,7 +524,7 @@ var getPackageVersion = () => {
493
524
  return "unknown";
494
525
  }
495
526
  };
496
- var SystematicPlugin = async ({ client, directory }) => {
527
+ var initializePlugin = async ({ client, directory }) => {
497
528
  const config = loadConfig(directory);
498
529
  const bootstrapContent = getBootstrapContent(config, { bundledSkillsDir });
499
530
  const configHandler = createConfigHandler({
@@ -551,6 +582,23 @@ var SystematicPlugin = async ({ client, directory }) => {
551
582
  }
552
583
  };
553
584
  };
585
+ var SystematicPlugin = async (input) => {
586
+ const { hooks } = await plugInOnce({
587
+ doInit: () => initializePlugin(input),
588
+ onDuplicate: (pid) => {
589
+ const message = `[systematic] duplicate factory invocation in same process (pid=${pid}); skipping duplicate registration. Multiple opencode.json sources may list this plugin.`;
590
+ console.warn(message);
591
+ input.client.app.log({
592
+ body: {
593
+ service: "systematic",
594
+ level: "warn",
595
+ message
596
+ }
597
+ }).catch(() => {});
598
+ }
599
+ });
600
+ return hooks;
601
+ };
554
602
  var src_default = SystematicPlugin;
555
603
  export {
556
604
  src_default as default
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Per-process register-once guard for the Systematic plugin factory.
3
+ *
4
+ * OpenCode invokes the plugin factory more than once per process when the
5
+ * same plugin is referenced by multiple config sources (for example a
6
+ * user-level `~/.config/opencode/opencode.json` AND a project-level
7
+ * `opencode.json`). Each invocation evaluates the plugin module fresh —
8
+ * module-local variables reset between calls — so the guard state must
9
+ * live on `globalThis` to persist across module instances within the
10
+ * same process.
11
+ *
12
+ * On the first invocation `doInit` runs and the resulting hooks promise
13
+ * is cached on `globalThis`; the caller receives `{ isFirst: true, hooks }`.
14
+ * On every subsequent invocation in the same PID `doInit` is skipped, the
15
+ * cached promise is awaited (so sticky rejections still propagate),
16
+ * `onDuplicate` fires exactly once, and the caller receives
17
+ * `{ isFirst: false, hooks: {} as T }`. Across PIDs the guard is treated
18
+ * as absent and init runs fresh — `globalThis` is per-process, but the
19
+ * explicit PID check adds defensive belt-and-suspenders against any
20
+ * state-leakage edge case.
21
+ *
22
+ * **Why empty hooks on duplicate invocations.** A whole-hooks singleton
23
+ * that returns the same hooks reference to both invocations does not
24
+ * deduplicate host-side tool registration: OpenCode iterates each plugin
25
+ * source's returned hook surface and registers every tool entry it finds,
26
+ * even when two sources return the same JS reference. Returning `{}` from
27
+ * the duplicate path is the only shape that prevents host-side double
28
+ * registration of `systematic_skill`, the `config` hook, and the
29
+ * `experimental.chat.system.transform` hook. The Phase 0 follow-up probe
30
+ * (see `docs/plans/2026-05-01-001-fix-idempotent-plugin-registration-plan.md`)
31
+ * verified the duplicate `systematic_skill` entry in OpenCode's
32
+ * `client.tool.list(...)` output, which is the LLM-visible tool catalog.
33
+ *
34
+ * **Known limitation — rejected init is sticky.** If `doInit()` rejects,
35
+ * the rejected promise is stored on `globalThis` and every subsequent
36
+ * invocation in the same PID returns the same rejection without retrying
37
+ * init. This is intentional: re-running heavy init on every call would
38
+ * defeat the guard's purpose. Recovery requires a process restart.
39
+ */
40
+ export interface PlugInOnceOptions<T> {
41
+ /** Heavy init work that should run at most once per process. */
42
+ doInit: () => Promise<T>;
43
+ /**
44
+ * Called exactly once on the first duplicate invocation in the same
45
+ * process. Subsequent duplicates are silent. Receives the same `pid`
46
+ * value the guard used for its identity check (so test overrides flow
47
+ * through faithfully). Implementations must not throw; fire-and-forget
48
+ * side effects (logging, metrics) are expected. Synchronous exceptions
49
+ * are swallowed defensively.
50
+ */
51
+ onDuplicate?: (pid: number) => void;
52
+ /** Test override; defaults to `process.pid`. */
53
+ pid?: number;
54
+ }
55
+ /**
56
+ * Result envelope for `plugInOnce(...)`.
57
+ *
58
+ * - `isFirst: true` — caller should return `hooks` to OpenCode.
59
+ * - `isFirst: false` — caller MUST return `hooks` (which is an empty `{}`)
60
+ * so the host loader does not register tools or hooks twice.
61
+ *
62
+ * Callers that just `return result.hooks` do the right thing in both
63
+ * cases without needing to inspect `isFirst`.
64
+ */
65
+ export interface PlugInOnceResult<T> {
66
+ isFirst: boolean;
67
+ hooks: T;
68
+ }
69
+ /**
70
+ * Run `doInit` at most once per process; on duplicate invocations resolve to
71
+ * empty hooks so the OpenCode host does not double-register tools and hooks.
72
+ */
73
+ export declare function plugInOnce<T>({ doInit, onDuplicate, pid, }: PlugInOnceOptions<T>): Promise<PlugInOnceResult<T>>;
74
+ /**
75
+ * Test-only: clear the singleton state so the next invocation re-runs
76
+ * init. Must not be called in production code paths.
77
+ */
78
+ export declare function _resetPluginSingleton(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "description": "Structured engineering workflows for OpenCode",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",