@jini-ai/agent-plugins 0.4.0

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/LICENSE +201 -0
  3. package/README.md +261 -0
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.js +2 -0
  6. package/dist/lifecycle/activation.d.ts +177 -0
  7. package/dist/lifecycle/activation.js +874 -0
  8. package/dist/lifecycle/bundled-digests.d.ts +27 -0
  9. package/dist/lifecycle/bundled-digests.js +149 -0
  10. package/dist/lifecycle/bundled-source-archive.d.ts +13 -0
  11. package/dist/lifecycle/bundled-source-archive.js +96 -0
  12. package/dist/lifecycle/capability-projection.d.ts +11 -0
  13. package/dist/lifecycle/capability-projection.js +60 -0
  14. package/dist/lifecycle/fetch-archive.d.ts +88 -0
  15. package/dist/lifecycle/fetch-archive.js +215 -0
  16. package/dist/lifecycle/index.d.ts +174 -0
  17. package/dist/lifecycle/index.js +94 -0
  18. package/dist/lifecycle/install-from-url.d.ts +78 -0
  19. package/dist/lifecycle/install-from-url.js +73 -0
  20. package/dist/lifecycle/install.d.ts +52 -0
  21. package/dist/lifecycle/install.js +286 -0
  22. package/dist/lifecycle/layout.d.ts +20 -0
  23. package/dist/lifecycle/layout.js +30 -0
  24. package/dist/lifecycle/manifest.d.ts +214 -0
  25. package/dist/lifecycle/manifest.js +409 -0
  26. package/dist/lifecycle/mcp-provisioning.d.ts +20 -0
  27. package/dist/lifecycle/mcp-provisioning.js +14 -0
  28. package/dist/lifecycle/node.d.ts +11 -0
  29. package/dist/lifecycle/node.js +34 -0
  30. package/dist/lifecycle/package-paths.d.ts +12 -0
  31. package/dist/lifecycle/package-paths.js +74 -0
  32. package/dist/lifecycle/ports.d.ts +81 -0
  33. package/dist/lifecycle/ports.js +2 -0
  34. package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +18 -0
  35. package/dist/lifecycle/resolve-agent-plugin-refs.js +143 -0
  36. package/dist/lifecycle/retire-bundled.d.ts +36 -0
  37. package/dist/lifecycle/retire-bundled.js +69 -0
  38. package/dist/lifecycle/search.d.ts +23 -0
  39. package/dist/lifecycle/search.js +50 -0
  40. package/dist/lifecycle/seed-bundled.d.ts +29 -0
  41. package/dist/lifecycle/seed-bundled.js +118 -0
  42. package/dist/lifecycle/set-enabled.d.ts +21 -0
  43. package/dist/lifecycle/set-enabled.js +39 -0
  44. package/dist/lifecycle/trusted-plugin-files.d.ts +29 -0
  45. package/dist/lifecycle/trusted-plugin-files.js +82 -0
  46. package/dist/lifecycle/types.d.ts +20 -0
  47. package/dist/lifecycle/types.js +2 -0
  48. package/dist/lifecycle/uninstall.d.ts +41 -0
  49. package/dist/lifecycle/uninstall.js +148 -0
  50. package/dist/lifecycle/yauzl-archive-reader.d.ts +62 -0
  51. package/dist/lifecycle/yauzl-archive-reader.js +73 -0
  52. package/dist/manifest.d.ts +68 -0
  53. package/dist/manifest.js +38 -0
  54. package/package.json +92 -0
  55. package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  56. package/ui-ux-design/mcp.json +4 -0
  57. package/ui-ux-design/plugin.json +5 -0
  58. package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
  59. package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
  60. package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
  61. package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
  62. package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
  63. package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
  64. package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
  65. package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
  66. package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
  67. package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
  68. package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
  69. package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
  70. package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
  71. package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
  72. package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
  73. package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
  74. package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
  75. package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
  76. package/ui-ux-design/skills/shadcn-ui/SKILL.md +326 -0
  77. package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
  78. package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
  79. package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
  80. package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
  81. package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
  82. package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
  83. package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
  84. package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
  85. package/ui-ux-design/skills/ui-ux-design/SKILL.md +77 -0
  86. package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +72 -0
  87. package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +107 -0
  88. package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +65 -0
  89. package/ui-ux-design/skills/ui-ux-design/references/foundations.md +144 -0
  90. package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
  91. package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +102 -0
  92. package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
  93. package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +63 -0
  94. package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +78 -0
  95. package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +93 -0
  96. package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +123 -0
  97. package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +57 -0
  98. package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
  99. package/ui-ux-design/skills/web-compliance/SKILL.md +129 -0
  100. package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
@@ -0,0 +1,874 @@
1
+ /**
2
+ * Historical lifecycle rationale, carried with the implementation during consumer adoption.
3
+ * @file Per-workspace activation state for installed Agent Plugins — the record that makes
4
+ * "bundled but inactive" a real, enforced condition rather than a note in a README.
5
+ *
6
+ * ---------------------------------------------------------------------------
7
+ * Why this exists at all
8
+ * ---------------------------------------------------------------------------
9
+ * Until now there was no such record anywhere: `resolve-agent-plugin-refs.ts`'s own header says so
10
+ * ("There is no record anywhere — confirmed 2026-08-21, zero hits in `src/platform/db/` and `src/server/` —
11
+ * of which installed digest is 'the' current install"), and a package's mere presence under
12
+ * `packages/sha256/*` was the whole of its authorization to be discovered, registered as a tool,
13
+ * and injected into a run's prompt.
14
+ *
15
+ * That was fine while every package on disk got there because an operator ran an install command.
16
+ * It stops being fine the moment the host itself pre-places a package (`seed-bundled.ts`), because then
17
+ * presence-implies-consent silently converts a vendor decision into an operator one.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * The default is "absent means active", and that is deliberate
21
+ * ---------------------------------------------------------------------------
22
+ * {@link isAgentPluginActive} treats a plugin with NO record as active. This looks backwards for a
23
+ * feature whose point is inactivity, so the reasoning is stated plainly:
24
+ *
25
+ * - Every plugin already installed on every existing host instance got there by an operator
26
+ * explicitly invoking the host installer. That IS the consent. Defaulting those to
27
+ * inactive would silently break every current install on upgrade, to re-collect consent that was
28
+ * already given.
29
+ * - A bundled package has no such act behind it. So the seeder writes an explicit
30
+ * `{ enabled: false, origin: "bundled" }` record AT SEED TIME, before anything can read it — and
31
+ * because seeding is idempotent and re-runs on every boot, deleting `activations.json` re-creates
32
+ * the disabled record rather than promoting the bundled plugin to active. The unsafe direction is
33
+ * closed; the safe one (an operator's own install keeps working) is left open.
34
+ *
35
+ * The net rule, then: **presence implies consent only for packages an operator put there.** The
36
+ * record exists to say "the host put this here, and nobody has said yes yet."
37
+ *
38
+ * ---------------------------------------------------------------------------
39
+ * ...but "absent" only means active when the file itself was READ successfully
40
+ * ---------------------------------------------------------------------------
41
+ * {@link readAgentPluginActivations} collapses a missing file, an unreadable one, a malformed one,
42
+ * and one with the wrong envelope into the same empty record — fail-OPEN, for the reason its own
43
+ * doc gives. That is right for the two surfaces that only DISCOVER plugins (a corrupt byte must not
44
+ * silently hide every plugin an operator installed), and wrong for the one surface that decides
45
+ * whether a capability may be SPENT: there, "I could not read the operator's decisions" is not a
46
+ * statement that everything is permitted.
47
+ *
48
+ * {@link resolveAgentPluginActivation} is that stricter reader, added 2026-09-16 for
49
+ * `tool-registrations.ts`'s per-call tool gate. It separates the two things
50
+ * {@link readAgentPluginActivations} folds together:
51
+ *
52
+ * - **no file, or a well-formed file with no entry for this plugin** — nothing was recorded, so the
53
+ * "absent means active" rule above applies unchanged, and it answers `active`;
54
+ * - **a file that exists but could not be read, parsed, or shape-checked, or an entry for THIS
55
+ * plugin that fails normalization** — a decision may well have been recorded and cannot be read,
56
+ * so it answers `undetermined`, and the gate denies.
57
+ *
58
+ * The header's own objection to fail-CLOSED ("no error surface to notice it by") is answered at that
59
+ * call site and only there: a denied invocation is recorded as `denied` in the daemon's audit trail
60
+ * and the gate logs the reason, so a corrupt file is loud rather than silent. Nothing about what
61
+ * "active" MEANS differs between the two readers — only whether a fault is allowed to masquerade as
62
+ * an answer.
63
+ *
64
+ * ---------------------------------------------------------------------------
65
+ * Why a file and not a table
66
+ * ---------------------------------------------------------------------------
67
+ * All three places that must consult it — `capability-source.ts` (discovery),
68
+ * `tool-registrations.ts` (tool registration), `resolve-agent-plugin-refs.ts` (run-start injection)
69
+ * — receive an `AgentPluginWorkspaceLayout` or a `workspaceId`, and NONE of them has a database
70
+ * handle. Threading one through would mean giving three pure-filesystem call paths a repo
71
+ * dependency (and the agent daemon is a second OS process with its own handle over the same WAL —
72
+ * `agent-daemon-server.ts:298-299` — so the two would also have to agree about freshness).
73
+ *
74
+ * Everything else about an installed Agent Plugin already lives on disk under the workspace's own
75
+ * tenant-isolated root; its activation living beside it needs no new seam, no migration, and no
76
+ * cross-process cache-coherence argument. `plugin_activations` (`db/schema.sqlite.ts`) is the SPEC-005
77
+ * `.host-plugin` runtime's table for a different plugin system with a different lifecycle — reusing
78
+ * it would conflate two unrelated things that merely share a word.
79
+ *
80
+ * ---------------------------------------------------------------------------
81
+ * Writers never rewrite what they could not read (2026-09-16, t91 F1.1)
82
+ * ---------------------------------------------------------------------------
83
+ * Every writer below used to build its next state from {@link readAgentPluginActivations}'s LENIENT
84
+ * view — the same "unreadable folds to empty" read that is correct for discovery — and then wrote
85
+ * that view straight back out. Against a corrupt file, that laundered every operator decision inside
86
+ * it into a fresh, well-formed file recording NOTHING: a disabled plugin's `enabled: false` record
87
+ * simply vanished, and "absent means active" (this header, above) turned it active again. A second,
88
+ * narrower arm of the same bug survived even a whole-file fix: the writers built their next state
89
+ * from the NORMALIZED bag, which silently drops any single malformed entry — so toggling one
90
+ * unrelated plugin was enough to erase a neighboring entry the gate was correctly treating as
91
+ * `undetermined` (denied), re-admitting it as `active` the moment the rewrite landed.
92
+ *
93
+ * The rule now: every writer reads the RAW `plugins` bag with {@link readPluginsBagStrict} — which
94
+ * throws {@link AgentPluginActivationsUnreadableError} on an unreadable file instead of returning an
95
+ * empty one — and edits that raw bag directly, preserving byte-for-byte every entry it is not the
96
+ * one changing (malformed or not). `absent` (no file yet) still starts empty, so a first boot seeds
97
+ * exactly as before; only a file that EXISTS and cannot be read, parsed, or shape-checked refuses.
98
+ *
99
+ * The boot consequence is `seed-bundled.ts`'s to own (see that file's header): a corrupt file makes
100
+ * the seeder install nothing and report every bundled plugin `failed`, rather than write around the
101
+ * fault. Nothing here throws on the read `readAgentPluginActivations` itself still uses for
102
+ * discovery — that reader, and its "absent/unreadable both fold to empty" contract, are unchanged.
103
+ *
104
+ * ---------------------------------------------------------------------------
105
+ * Concurrency
106
+ * ---------------------------------------------------------------------------
107
+ * Writes are write-temp-then-`rename`, with a uniquely named temp file per write, so a reader never
108
+ * observes a half-written file.
109
+ *
110
+ * Within ONE process every read-modify-write of a workspace's file is serialized
111
+ * ({@link serializeActivationsWrite}). This used to be waved off as "a race nobody has, whose worst
112
+ * case is a visibly wrong checkbox". Both halves were false (t91 review, 2026-09-16): the admin
113
+ * Agent Plugins screen deliberately lets two rows toggle at once, and each row renders its own
114
+ * PATCH response — so when two writes both read the same file and the second `rename` erased the
115
+ * first one's `enabled: false`, the operator saw the plugin OFF while "absent means active" had
116
+ * already turned it back ON. Two writes in the same millisecond also shared one temp path, and one
117
+ * of them failed with `ENOENT`.
118
+ *
119
+ * ACROSS processes (t91, 2026-09-16, plan `agent-reports/2026-09-16-t91-plan-activations-lock.md`):
120
+ * every writer below also takes an exclusive, cross-process lock (`exclusive-file-lock.ts`) on
121
+ * `<root>/activations.json.lock` before its strict read, and holds it until the rename lands. The
122
+ * in-process chain above stays in FRONT of that lock — it still gives same-process writers FIFO
123
+ * order with no polling — and the lock is not reentrant, so the invariant is: **no exported function
124
+ * in this file may be called from inside a chain turn or a lock callback.** Such a call would wait on
125
+ * a lock (or a chain slot) it is already holding, and hang exactly like re-entering the chain would.
126
+ * `recordBundledAgentPluginIfAbsent` keeps using the private `writeActivationDecision` rather than
127
+ * calling `setAgentPluginActivation`, for this reason.
128
+ *
129
+ * A lock is judged stale — and broken, unblocking every waiter — if its holder is a dead process on
130
+ * this same host, or if it is simply older than 10s; see `exclusive-file-lock.ts`'s own header for
131
+ * the full protocol and every failure mode it closes. A writer that cannot take the lock within 15s,
132
+ * or that loses it (a stalled holder judged stale by someone else) right before its own rename,
133
+ * throws {@link AgentPluginActivationsBusyError} and writes NOTHING — never a partial or stale write.
134
+ *
135
+ * Residuals this does NOT close, stated rather than hidden:
136
+ * - R6: the boot seeder still installs a package before recording it inactive; a crash between the
137
+ * two leaves an unrecorded (and therefore active) bundled package. Pre-flighting the lock clears
138
+ * any stale lock before anything is installed, which narrows but does not remove this window.
139
+ * - The tiny gap between a holder's own `assertHeld()` and its `rename` — closed to "never a stale
140
+ * write survives it", not to "zero probability another process ever judged this lock stale in that
141
+ * instant".
142
+ * - A network filesystem, or a lock left by a crash that is later swept into site source control by
143
+ * `duplicate-site.ts`'s `cpSync` (this repo does not touch `sites/**` here to fix that).
144
+ * - A killed writer can leave a stray `activations.json.tmp-*` file; nothing currently sweeps those.
145
+ *
146
+ * Architectural role:
147
+ * Filesystem state for one workspace, reached only through `layout.ts`'s `forWorkspace()` root — the
148
+ * same tenant-isolation guarantee every other path in this feature goes through.
149
+ */
150
+ /** Extracted from the host plugin lifecycle. Effects are supplied per host context.
151
+ * Kernel exclusive create chooses one writer. The shared platform lock binds ownership to
152
+ * a unique token and inode/device, verifies unchanged bytes/mtime before stale removal, and
153
+ * never treats a permission failure as proof of a dead pid. No heartbeat extends the explicit
154
+ * 10-second stale budget; a longer live operation can lose authority and must check assertHeld
155
+ * before publication. The shared fixed polling replaces local jitter; activation keeps its
156
+ * 15-second timeout, timed on the clock port's monotonic reading so a wall-clock jump can neither
157
+ * end nor stretch the wait (stale age stays on wall time, matching lock-file mtimes), and
158
+ * translates shared errors into busy.
159
+ */
160
+ import path from "node:path";
161
+ import { FileLockLostError, FileLockTimeoutError } from "@jini-ai/platform/fs/file-lock";
162
+ import { withFileLock } from "@jini-ai/platform/fs/file-lock";
163
+ /**
164
+ * Where a workspace's activation record lives, relative to its own agent-plugin root.
165
+ */
166
+ export const ACTIVATIONS_FILENAME = "activations.json";
167
+ /**
168
+ * The cross-process lock every writer below takes before touching {@link ACTIVATIONS_FILENAME} —
169
+ * see this file's header, "Concurrency", ACROSS processes.
170
+ */
171
+ export const ACTIVATIONS_LOCK_FILENAME = `${ACTIVATIONS_FILENAME}.lock`;
172
+ /**
173
+ * A WRITER (or the strict provenance read) found the activations file present but unreadable, and
174
+ * refused rather than rewrite it — see this file's header, "Writers never rewrite what they could
175
+ * not read". `message` names the host path: keep it in a server-side log, never in a model- or
176
+ * user-facing payload.
177
+ */
178
+ export class AgentPluginActivationsUnreadableError extends Error {
179
+ filePath;
180
+ reason;
181
+ constructor({ filePath, reason }) {
182
+ super(`agent-plugin activation: ${reason} (${filePath}). The file was left untouched and nothing was written, so no ` +
183
+ "recorded enable/disable decision was lost. Until it is repaired, this workspace's Agent Plugin tool calls " +
184
+ "and plugin-pinned runs are refused, and no Agent Plugin can be enabled, disabled, seeded or uninstalled. " +
185
+ "To recover, fix the JSON by hand; or stop the host, move the file aside and start the host again — bundled plugins " +
186
+ "are then recorded as disabled again, but every operator-installed plugin you had disabled starts enabled " +
187
+ "and must be disabled again.");
188
+ this.filePath = filePath;
189
+ this.reason = reason;
190
+ this.name = "AgentPluginActivationsUnreadableError";
191
+ }
192
+ }
193
+ /**
194
+ * A WRITER could not take (or lost) the cross-process write lock in time — see this file's header,
195
+ * "Concurrency", ACROSS processes, and `exclusive-file-lock.ts`. `lost: true` means the lock WAS
196
+ * acquired but was judged stale by another process before this writer's own rename; `lost: false`
197
+ * means the initial acquisition itself timed out. Either way NOTHING was written. `message` names
198
+ * the host lock path: keep it in a server-side log, never in a model- or user-facing payload.
199
+ */
200
+ export class AgentPluginActivationsBusyError extends Error {
201
+ lockPath;
202
+ holder;
203
+ lost;
204
+ constructor({ lockPath, holder, lost }) {
205
+ super((lost
206
+ ? `agent-plugin activation: lost the write lock (${lockPath}) before committing — another process judged it stale`
207
+ : `agent-plugin activation: could not take the write lock (${lockPath}) within 15s` +
208
+ (holder !== undefined ? ` — held by pid ${holder.pid} on ${holder.hostname} since ${holder.acquiredAt}` : " — holder unknown")) +
209
+ ". Nothing was written, so no recorded enable/disable decision was changed. This clears by itself when the other " +
210
+ "host process (server, agent daemon, or the agent-plugin:activation CLI) finishes; a lock left by a crashed " +
211
+ "process is removed automatically once that process is gone or the lock is older than 10s. If it persists, stop " +
212
+ `every host process for this workspace and delete ${lockPath}.`);
213
+ this.lockPath = lockPath;
214
+ this.holder = holder;
215
+ this.lost = lost;
216
+ this.name = "AgentPluginActivationsBusyError";
217
+ }
218
+ }
219
+ function buildModule(ports) {
220
+ const randomUUID = () => ports.ids.newId();
221
+ const mkdir = ports.filesystem.mkdir.bind(ports.filesystem);
222
+ const open = ports.filesystem.open.bind(ports.filesystem);
223
+ const readFile = ports.filesystem.readFile.bind(ports.filesystem);
224
+ const rename = ports.filesystem.rename.bind(ports.filesystem);
225
+ const rm = ports.filesystem.rm.bind(ports.filesystem);
226
+ const EMPTY_ACTIVATIONS = { schemaVersion: 1, plugins: {} };
227
+ /**
228
+ * Same grammar `layout.ts` guards path segments with — an id that reaches this module is used as
229
+ * an object key, not a path, but validating in both places keeps a future caller from being the
230
+ * first one to find out the two disagreed.
231
+ */
232
+ const SAFE_PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
233
+ /**
234
+ * Whether a plugin may be discovered, registered as a tool, or injected into a run.
235
+ *
236
+ * @param activations - This workspace's record, as returned by {@link readAgentPluginActivations}.
237
+ * @param pluginId - The plugin's manifest `name`.
238
+ * @returns `false` only when an explicit record says `enabled: false`. See this file's header for
239
+ * why an absent record means active.
240
+ * @complexity O(1).
241
+ *
242
+ * `Object.hasOwn`, not a plain lookup: `activations.plugins` comes straight from `JSON.parse` (via
243
+ * `normalizeActivations`), so it still carries `Object.prototype`, and an id like `constructor` or
244
+ * `hasownproperty` passes the plugin-name grammar and would otherwise resolve to an inherited
245
+ * value rather than a missing entry — see `resolveAgentPluginActivation`'s identical guard.
246
+ */
247
+ function isAgentPluginActive(activations, pluginId) {
248
+ if (!Object.hasOwn(activations.plugins, pluginId))
249
+ return true;
250
+ return activations.plugins[pluginId].enabled;
251
+ }
252
+ /**
253
+ * Filters any list of plugin-shaped values down to the ones this workspace has active.
254
+ *
255
+ * Takes the id accessor rather than a concrete type so the three gate sites — which hold
256
+ * `InstalledAgentPlugin`s, `AgentPluginToolSource`s, and capability cards respectively — all share
257
+ * one implementation of the rule instead of three `filter` calls that could drift apart on what
258
+ * "active" means.
259
+ *
260
+ * @complexity O(n) in the list length.
261
+ */
262
+ function filterActiveAgentPlugins(activations, items, pluginIdOf) {
263
+ return items.filter((item) => isAgentPluginActive(activations, pluginIdOf(item)));
264
+ }
265
+ /**
266
+ * Reads one workspace's activation record.
267
+ *
268
+ * @param workspaceRoot - `AgentPluginWorkspaceLayout.root`, never an instance-level path.
269
+ * @returns The parsed record, or an empty one when the file does not exist yet — which is the
270
+ * normal state for a workspace that has never installed or been seeded anything.
271
+ * @throws Nothing for a missing, unreadable, malformed, or wrong-version file: all of those return
272
+ * an empty record. That is fail-OPEN, and it is the correct direction here specifically because this
273
+ * reader is used for DISCOVERY only — nothing that SPENDS a capability or WRITES the file uses it
274
+ * any more (t91 F1.1, 2026-09-16): the per-call tool gate and run-start injection both use the
275
+ * fail-CLOSED {@link resolveAgentPluginActivation}, and every writer below reads the strict raw bag
276
+ * and refuses outright on an unreadable file (see this file's header, "Writers never rewrite what
277
+ * they could not read").
278
+ * @complexity O(p) in the recorded plugin count.
279
+ */
280
+ async function readAgentPluginActivations(workspaceRoot) {
281
+ /**
282
+ * `Object.hasOwn`, not a plain lookup: the bag comes straight from `JSON.parse`, so it still
283
+ * carries `Object.prototype`, and several ids that pass the name grammar (`constructor`,
284
+ * `toString`, ...) would otherwise resolve to an inherited function rather than a missing entry.
285
+ */
286
+ const document = await readActivationsDocument(workspaceRoot);
287
+ return document.kind === "entries" ? normalizePluginsBag(document.entries) : EMPTY_ACTIVATIONS;
288
+ }
289
+ /**
290
+ * Reads and shape-checks the activations file once, for BOTH readers above and below — so the
291
+ * lenient and the strict view can never drift apart on what parses, only on what they do about a
292
+ * fault.
293
+ *
294
+ * @complexity One file read plus one `JSON.parse` in the file's own size.
295
+ *
296
+ * ENOENT — including a workspace root that does not exist yet — is the ordinary never-recorded
297
+ * state, not a fault. Anything else (EACCES, ENOTDIR, EIO, EMFILE, ...) is a real failure to
298
+ * read a file that may well hold a decision.
299
+ */
300
+ async function readActivationsDocument(workspaceRoot) {
301
+ let raw;
302
+ try {
303
+ raw = await readFile(path.join(workspaceRoot, ACTIVATIONS_FILENAME), "utf8");
304
+ }
305
+ catch (error) {
306
+ if (errorCode(error) === "ENOENT")
307
+ return { kind: "absent" };
308
+ return { kind: "unreadable", reason: `${ACTIVATIONS_FILENAME} could not be read (${describeError(error)})` };
309
+ }
310
+ let parsed;
311
+ try {
312
+ parsed = JSON.parse(raw);
313
+ }
314
+ catch (error) {
315
+ return { kind: "unreadable", reason: `${ACTIVATIONS_FILENAME} is not valid JSON (${describeError(error)})` };
316
+ }
317
+ const entries = extractPluginsBag(parsed);
318
+ if (entries === undefined) {
319
+ return { kind: "unreadable", reason: `${ACTIVATIONS_FILENAME} is not a schemaVersion 1 activations document` };
320
+ }
321
+ return { kind: "entries", entries };
322
+ }
323
+ function errorCode(error) {
324
+ if (typeof error !== "object" || error === null || !("code" in error))
325
+ return undefined;
326
+ const code = error.code;
327
+ return typeof code === "string" ? code : undefined;
328
+ }
329
+ /**
330
+ * A short, log-safe rendering of a thrown value. Carries the error's own message (which for a
331
+ * filesystem fault names this workspace's own path — a host path, so a caller that surfaces this
332
+ * must keep it in a server-side log and out of any model- or user-facing payload).
333
+ */
334
+ function describeError(error) {
335
+ return error instanceof Error ? error.message : String(error);
336
+ }
337
+ /**
338
+ * Turns a lock failure from {@link withExclusiveFileLock} into {@link AgentPluginActivationsBusyError};
339
+ * any other error (including a thrown {@link AgentPluginActivationsUnreadableError} from the read
340
+ * inside the callback) passes through unchanged. @complexity O(1).
341
+ */
342
+ function toActivationsBusyError(lockPath, error) {
343
+ if (error instanceof FileLockTimeoutError)
344
+ return new AgentPluginActivationsBusyError({ lockPath: lockPath, holder: error.holder, lost: false });
345
+ if (error instanceof FileLockLostError)
346
+ return new AgentPluginActivationsBusyError({ lockPath: lockPath, holder: undefined, lost: true });
347
+ return error;
348
+ }
349
+ /**
350
+ * Logs a stale lock's removal — server-side only, since the path and pid are host detail.
351
+ * @complexity O(1).
352
+ */
353
+ function warnStaleActivationsLockRemoved(lockPath, holder) {
354
+ ports.onEvent?.({ event: "stale-activation-lock-removed", message: (holder !== undefined
355
+ ? `[agent-plugins] removed a stale activations.json lock left by pid ${holder.pid} on ${holder.hostname} (acquired ${holder.acquiredAt}): ${lockPath}`
356
+ : `[agent-plugins] removed a stale activations.json lock with an unreadable holder: ${lockPath}`) });
357
+ }
358
+ /**
359
+ * The strict read every writer uses: `absent` (no file yet) is an empty bag, same as
360
+ * {@link readAgentPluginActivations}; a file that exists but cannot be read, parsed, or
361
+ * shape-checked refuses instead of folding to empty.
362
+ *
363
+ * @throws {AgentPluginActivationsUnreadableError} The file exists but cannot be read.
364
+ * @complexity One file read plus one `JSON.parse` in the file's own size.
365
+ */
366
+ async function readPluginsBagStrict(workspaceRoot) {
367
+ const document = await readActivationsDocument(workspaceRoot);
368
+ if (document.kind === "unreadable") {
369
+ throw new AgentPluginActivationsUnreadableError({ filePath: path.join(workspaceRoot, ACTIVATIONS_FILENAME), reason: document.reason });
370
+ }
371
+ return document.kind === "entries" ? document.entries : {};
372
+ }
373
+ /**
374
+ * Whether the RAW entry for `pluginId` records `origin: "bundled"` — read raw so a malformed
375
+ * entry's provenance still survives a toggle. `Object.hasOwn`, not a plain lookup, for the same
376
+ * `Object.prototype` reason {@link resolveAgentPluginActivation} uses it. @complexity O(1).
377
+ */
378
+ function isRecordedAsBundled(bag, pluginId) {
379
+ if (!Object.hasOwn(bag, pluginId))
380
+ return false;
381
+ const entry = bag[pluginId];
382
+ return isPlainObject(entry) && entry.origin === "bundled";
383
+ }
384
+ /**
385
+ * Pre-flight for a caller about to do work whose record it must then write — the boot seeder calls
386
+ * this BEFORE installing anything, so a bundled package never lands on disk without the activation
387
+ * record that would keep it inactive.
388
+ *
389
+ * Also proves the cross-process write lock can actually be taken right now, and clears a stale one
390
+ * left by a crashed process — the same lock every real writer below takes (this file's header,
391
+ * "Concurrency", ACROSS processes).
392
+ *
393
+ * @throws {AgentPluginActivationsUnreadableError} The file exists but cannot be read.
394
+ * @throws {AgentPluginActivationsBusyError} The lock could not be taken within 15s.
395
+ * @complexity Same as {@link readPluginsBagStrict}, plus one lock acquisition/release.
396
+ */
397
+ async function assertAgentPluginActivationsWritable(workspaceRoot) {
398
+ await lockedActivationsWrite(workspaceRoot, () => readPluginsBagStrict(workspaceRoot));
399
+ }
400
+ /**
401
+ * The fail-CLOSED provenance read `uninstall.ts`'s bundled refusal uses in place of the lenient
402
+ * {@link readAgentPluginActivations} — so a corrupt file refuses an uninstall rather than reading as
403
+ * "no bundled record, therefore removable".
404
+ *
405
+ * @throws {AgentPluginActivationsUnreadableError} The file exists but cannot be read.
406
+ * @complexity Same as {@link readPluginsBagStrict}.
407
+ */
408
+ async function isAgentPluginRecordedAsBundled(workspaceRoot, pluginId) {
409
+ return isRecordedAsBundled(await readPluginsBagStrict(workspaceRoot), pluginId);
410
+ }
411
+ /**
412
+ * The fail-CLOSED read of ONE plugin's activation state — see this file's header for why the
413
+ * per-call tool gate needs a third answer that {@link isAgentPluginActive} cannot express.
414
+ *
415
+ * `active` still covers the "absent means active" rule unchanged: no file at all, or a well-formed
416
+ * file that simply has no entry for this plugin. `undetermined` covers only genuine faults — a file
417
+ * that exists but cannot be read, parsed, or shape-checked, or an entry for THIS plugin that fails
418
+ * normalization (a non-object entry, a non-boolean `enabled`). A malformed entry is deliberately NOT
419
+ * treated the way {@link normalizeActivations} treats it for discovery, where dropping it and
420
+ * falling back to "absent, therefore active" is harmless: here that fallback would read a garbled
421
+ * decision as consent.
422
+ *
423
+ * @param workspaceRoot - `AgentPluginWorkspaceLayout.root`, never an instance-level path.
424
+ * @param pluginId - The plugin's manifest `name`.
425
+ * @returns The verdict, with a log-safe reason on `undetermined`.
426
+ * @throws Nothing — every failure is folded into `undetermined`.
427
+ * @complexity One file read plus O(1) lookup.
428
+ */
429
+ async function resolveAgentPluginActivation(workspaceRoot, pluginId) {
430
+ const document = await readActivationsDocument(workspaceRoot);
431
+ if (document.kind === "unreadable")
432
+ return { verdict: "undetermined", reason: document.reason };
433
+ if (document.kind === "absent")
434
+ return { verdict: "active" };
435
+ if (!Object.hasOwn(document.entries, pluginId))
436
+ return { verdict: "active" };
437
+ const record = normalizeActivationEntry(pluginId, document.entries[pluginId]);
438
+ if (record === undefined) {
439
+ return { verdict: "undetermined", reason: `${ACTIVATIONS_FILENAME} holds an unreadable record for '${pluginId}'` };
440
+ }
441
+ return record.enabled ? { verdict: "active" } : { verdict: "inactive" };
442
+ }
443
+ /**
444
+ * Whether `value` is a plain (non-null, non-array) object — the shape every one of this file's
445
+ * JSON-adjacent values (the activations envelope, and each individual plugin entry) must have
446
+ * before its own fields are worth looking at.
447
+ */
448
+ function isPlainObject(value) {
449
+ return typeof value === "object" && value !== null && !Array.isArray(value);
450
+ }
451
+ /**
452
+ * The `plugins` bag out of an already-`JSON.parse`d activations value, or `undefined` when the
453
+ * top-level envelope doesn't match this schema version at all (wrong shape, wrong
454
+ * `schemaVersion`, or a non-object `plugins`) — collapsing the three top-level shape gates
455
+ * {@link normalizeActivations} used to inline into one lookup its loop can build on.
456
+ */
457
+ function extractPluginsBag(value) {
458
+ if (!isPlainObject(value) || value.schemaVersion !== 1 || !isPlainObject(value.plugins))
459
+ return undefined;
460
+ return value.plugins;
461
+ }
462
+ /**
463
+ * Fills in an activation record's optional fields from a (validated-boolean-`enabled`) raw entry.
464
+ * Pure defaulting: no field here can fail validation, only fall back to a safe default — matching
465
+ * the file's own "fail-OPEN per malformed field, never per entry" discipline (see this file's
466
+ * header).
467
+ */
468
+ function withActivationDefaults(record, enabled) {
469
+ return {
470
+ enabled,
471
+ origin: record.origin === "bundled" ? "bundled" : "operator-installed",
472
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date(0).toISOString(),
473
+ updatedBy: typeof record.updatedBy === "string" ? record.updatedBy : "unknown",
474
+ };
475
+ }
476
+ /**
477
+ * One `plugins` entry, normalized — or `undefined` when it does not match the shape (bad plugin
478
+ * id, non-object entry, or a non-boolean `enabled`), so {@link normalizeActivations}'s loop stays a
479
+ * simple accumulate-or-skip.
480
+ */
481
+ function normalizeActivationEntry(pluginId, entry) {
482
+ if (!SAFE_PLUGIN_ID_PATTERN.test(pluginId))
483
+ return undefined;
484
+ const record = isPlainObject(entry) ? entry : undefined;
485
+ if (record === undefined || typeof record.enabled !== "boolean")
486
+ return undefined;
487
+ return withActivationDefaults(record, record.enabled);
488
+ }
489
+ /**
490
+ * Validates an already-`JSON.parse`d activations value, dropping anything that does not match the
491
+ * shape. Pure, so the parsing rules are assertable without touching a filesystem.
492
+ *
493
+ * Per-entry rather than all-or-nothing: one malformed entry must not discard the operator's other,
494
+ * perfectly good decisions.
495
+ *
496
+ * @complexity O(p) in the entry count.
497
+ */
498
+ function normalizeActivations(value) {
499
+ /**
500
+ * Provenance is a fact about how the package arrived, not about this decision (comment kept
501
+ * from the pre-F1.1 version): an existing value is preserved rather than overwritten by a
502
+ * toggle. Read raw (`isRecordedAsBundled`), not through normalization, so a malformed sibling
503
+ * entry does not lose its own provenance either.
504
+ */
505
+ const plugins = extractPluginsBag(value);
506
+ if (plugins === undefined)
507
+ return EMPTY_ACTIVATIONS;
508
+ return normalizePluginsBag(plugins);
509
+ }
510
+ /**
511
+ * The per-entry accumulate-or-skip loop itself, split out of {@link normalizeActivations} so
512
+ * {@link readAgentPluginActivations} can run it over an ALREADY shape-checked bag rather than
513
+ * re-deriving one — one implementation of "which entries survive", not two.
514
+ *
515
+ * @complexity O(p) in the entry count.
516
+ */
517
+ function normalizePluginsBag(plugins) {
518
+ const normalized = {};
519
+ for (const [pluginId, entry] of Object.entries(plugins)) {
520
+ const record = normalizeActivationEntry(pluginId, entry);
521
+ if (record !== undefined)
522
+ normalized[pluginId] = record;
523
+ }
524
+ return { schemaVersion: 1, plugins: normalized };
525
+ }
526
+ /**
527
+ * Records an operator's explicit activation decision for one plugin.
528
+ *
529
+ * Reads and writes the RAW `plugins` bag, not the normalized view — see this file's header,
530
+ * "Writers never rewrite what they could not read" — so a malformed sibling entry (or any field a
531
+ * future schema version adds) survives this write byte-for-byte.
532
+ *
533
+ * @throws {Error} If `pluginId` does not match the Agent Plugins name grammar.
534
+ * @throws {AgentPluginActivationsUnreadableError} The file exists but cannot be read.
535
+ * @throws {AgentPluginActivationsBusyError} The cross-process write lock could not be taken or was
536
+ * lost; nothing was written.
537
+ * @returns The record as written, normalized, so a caller does not have to re-read to confirm.
538
+ * @complexity O(p) in the recorded plugin count — the whole (small) file is rewritten.
539
+ */
540
+ async function setAgentPluginActivation(required, optional = {}) {
541
+ assertPluginId(required.pluginId);
542
+ return lockedActivationsWrite(required.workspaceRoot, async (lock) => writeActivationDecision(await readPluginsBagStrict(required.workspaceRoot), required, optional, lock));
543
+ }
544
+ /**
545
+ * The write half of {@link setAgentPluginActivation}, over a bag the caller has ALREADY read strictly
546
+ * inside its own {@link lockedActivationsWrite} turn — so {@link recordBundledAgentPluginIfAbsent} can
547
+ * decide and write within one turn instead of re-entering the chain or the lock, either of which
548
+ * would wait on itself (this file's header, "Concurrency").
549
+ *
550
+ * @complexity O(p) in the recorded plugin count — the whole (small) file is rewritten.
551
+ */
552
+ async function writeActivationDecision(current, required, optional, lock) {
553
+ const { workspaceRoot, pluginId, enabled, actor } = required;
554
+ assertTargetEntryReadable(workspaceRoot, current, pluginId);
555
+ const plugins = {
556
+ ...current,
557
+ [pluginId]: {
558
+ enabled,
559
+ origin: optional.origin ?? (isRecordedAsBundled(current, pluginId) ? "bundled" : "operator-installed"),
560
+ updatedAt: (optional.now?.({}) ?? new Date(ports.clock.nowMs())).toISOString(),
561
+ updatedBy: actor,
562
+ },
563
+ };
564
+ await writeActivationsAtomically(workspaceRoot, plugins, lock);
565
+ return normalizePluginsBag(plugins);
566
+ }
567
+ /**
568
+ * N1 (reviewer finding, 2026-09-16, `agent-reports/2026-09-16-t91-review-plugin-part2.md`): refuses
569
+ * a write that would derive `origin` from a MALFORMED entry for the plugin actually being toggled,
570
+ * rather than silently "fixing" it as `operator-installed`.
571
+ *
572
+ * Without this, toggling a plugin whose OWN entry is present but malformed (e.g. its raw value is
573
+ * the bare string `"bundled"`, not `{ origin: "bundled", ... }`) rewrote that entry as a well-formed
574
+ * `operator-installed` record via {@link isRecordedAsBundled}'s `false` fallback — which both defeats
575
+ * `uninstall.ts`'s bundled-refusal for malformed entries (`583d2f5e`) and re-seeds the plugin as
576
+ * bundled-and-inactive on the next boot, since {@link recordBundledAgentPluginIfAbsent}'s own
577
+ * `Object.hasOwn` no-overwrite check can no longer tell "an operator's real decision" apart from "a
578
+ * garbled record this write invented". Consistent with this file's "undetermined means refuse" rule
579
+ * (see the header and {@link resolveAgentPluginActivation}): an existing entry that cannot be read is
580
+ * left exactly as it is, never repaired by a guess. `recordBundledAgentPluginIfAbsent` never reaches
581
+ * this — it only calls {@link writeActivationDecision} when `Object.hasOwn(current, pluginId)` is
582
+ * already `false`.
583
+ *
584
+ * @throws {AgentPluginActivationsUnreadableError} `pluginId`'s own entry exists but does not
585
+ * normalize.
586
+ * @complexity O(1).
587
+ */
588
+ function assertTargetEntryReadable(workspaceRoot, current, pluginId) {
589
+ if (!Object.hasOwn(current, pluginId))
590
+ return;
591
+ if (normalizeActivationEntry(pluginId, current[pluginId]) !== undefined)
592
+ return;
593
+ throw new AgentPluginActivationsUnreadableError({ filePath: path.join(workspaceRoot, ACTIVATIONS_FILENAME), reason: `${ACTIVATIONS_FILENAME} holds an unreadable record for '${pluginId}', so it cannot be safely toggled` });
594
+ }
595
+ /**
596
+ * Writes the initial `{ enabled: false, origin: "bundled" }` record for a plugin the host itself placed
597
+ * on disk — and does nothing at all if any record already exists.
598
+ *
599
+ * The no-overwrite rule is the whole contract: this runs on every boot (see `seed-bundled.ts`), so
600
+ * overwriting would re-disable a plugin the operator had deliberately enabled, every restart.
601
+ *
602
+ * `optional.enabled: true` seeds the record ENABLED instead, for a bundled plugin core behaviour
603
+ * depends on (`seed-bundled.ts`'s `BUNDLED_AGENT_PLUGINS_SEEDED_ENABLED`). It makes one exception to
604
+ * no-overwrite, for the upgrade path: a record the seeder itself wrote disabled and nobody has
605
+ * touched since (`enabled: false`, `updatedBy: "system:seed"`) is switched on, so an existing site
606
+ * needs no manual step. Any record an operator wrote is still left alone.
607
+ *
608
+ * @returns `{ recorded: true }` when it wrote the initial disabled record, `{ recorded: false }`
609
+ * when a decision already existed and was left alone. A present-but-MALFORMED entry still counts as
610
+ * an existing decision — `Object.hasOwn`, not a shape check — because the gate is already answering
611
+ * `undetermined` (deny) for it; overwriting it with a fresh disabled record would be a write this
612
+ * function has no business making just because the entry it found looked wrong.
613
+ * @throws {Error} If `pluginId` does not match the Agent Plugins name grammar.
614
+ * @throws {AgentPluginActivationsUnreadableError} The file exists but cannot be read.
615
+ * @throws {AgentPluginActivationsBusyError} The cross-process write lock could not be taken or was
616
+ * lost; nothing was written.
617
+ * @complexity O(p) in the recorded plugin count.
618
+ */
619
+ async function recordBundledAgentPluginIfAbsent(required, optional = {}) {
620
+ assertPluginId(required.pluginId);
621
+ const enabled = optional.enabled === true;
622
+ return lockedActivationsWrite(required.workspaceRoot, async (lock) => {
623
+ /**
624
+ * Recorded as `updatedBy` when this call writes.
625
+ */
626
+ const current = await readPluginsBagStrict(required.workspaceRoot);
627
+ if (Object.hasOwn(current, required.pluginId) && !(enabled && isUntouchedDisabledSeed(current[required.pluginId]))) {
628
+ return { recorded: false };
629
+ }
630
+ await writeActivationDecision(current, { workspaceRoot: required.workspaceRoot, pluginId: required.pluginId, enabled, actor: SEED_ACTOR }, { origin: "bundled", ...(optional.now !== undefined ? { now: optional.now } : {}) }, lock);
631
+ return { recorded: true };
632
+ });
633
+ }
634
+ /**
635
+ * `updatedBy` of every record the boot seeder writes.
636
+ */
637
+ const SEED_ACTOR = "system:seed";
638
+ /**
639
+ * Whether a RAW entry is the seeder's own disabled record that no operator has touched since.
640
+ * @complexity O(1).
641
+ */
642
+ function isUntouchedDisabledSeed(entry) {
643
+ return isPlainObject(entry) && entry.enabled === false && entry.updatedBy === SEED_ACTOR;
644
+ }
645
+ /**
646
+ * Switches a bundled plugin ON unless an operator has explicitly turned it off — for
647
+ * `retire-bundled.ts`, which moves a retired plugin's users onto its successor.
648
+ *
649
+ * Enables when there is no record, or the record is the seeder's own untouched disabled one (the
650
+ * same exception {@link recordBundledAgentPluginIfAbsent} makes). A disabled record anyone else wrote
651
+ * is an operator decision and is left exactly as it is; the caller reports it rather than
652
+ * overriding it. Decided and written inside one locked turn, so a toggle racing the boot cannot be
653
+ * overwritten by a decision made on a stale read.
654
+ *
655
+ * @throws {Error} If `pluginId` does not match the Agent Plugins name grammar.
656
+ * @throws {AgentPluginActivationsUnreadableError} The file, or `pluginId`'s own entry, cannot be read.
657
+ * @throws {AgentPluginActivationsBusyError} The cross-process write lock could not be taken or was
658
+ * lost; nothing was written.
659
+ * @complexity O(p) in the recorded plugin count.
660
+ */
661
+ async function enableBundledAgentPluginUnlessOperatorDisabled(required) {
662
+ assertPluginId(required.pluginId);
663
+ const { workspaceRoot, pluginId, actor } = required;
664
+ return lockedActivationsWrite(workspaceRoot, async (lock) => {
665
+ const current = await readPluginsBagStrict(workspaceRoot);
666
+ if (Object.hasOwn(current, pluginId) && !isUntouchedDisabledSeed(current[pluginId])) {
667
+ assertTargetEntryReadable(workspaceRoot, current, pluginId);
668
+ return normalizeActivationEntry(pluginId, current[pluginId])?.enabled === true ? "already-enabled" : "left-disabled-by-operator";
669
+ }
670
+ await writeActivationDecision(current, { workspaceRoot, pluginId, enabled: true, actor }, { origin: "bundled", ...(required.now !== undefined ? { now: required.now } : {}) }, lock);
671
+ return "enabled";
672
+ });
673
+ }
674
+ /**
675
+ * Removes a plugin's activation record entirely, rather than leaving a disabled tombstone.
676
+ *
677
+ * Callers: `uninstall.ts`'s `uninstallAgentPlugin()`, on a successful uninstall (see that file's own
678
+ * header for the full decision), and `retire-bundled.ts`, for a retired bundled plugin's leftover
679
+ * record when no package of it is installed (2026-09-29).
680
+ * Short version: a tombstone (`enabled: false` left behind after the bytes are gone) would invert
681
+ * this file's own "absent means active, because an operator's own install IS the consent" rule
682
+ * (this file's header, above) the moment the SAME plugin id is ever reinstalled — the fresh install
683
+ * would silently inherit a stale disabled flag about bytes that no longer exist. Deleting the record
684
+ * makes a future reinstall of this id start exactly where any first-ever install starts.
685
+ *
686
+ * A no-op, not an error, when no record exists — matches `recordBundledAgentPluginIfAbsent`'s own
687
+ * idempotent style; a caller uninstalling a plugin that was never explicitly toggled should not have
688
+ * to special-case "there was nothing to clear".
689
+ *
690
+ * @throws {Error} If `pluginId` does not match the Agent Plugins name grammar.
691
+ * @throws {AgentPluginActivationsUnreadableError} The file exists but cannot be read — an unreadable
692
+ * file might well hold a record for `pluginId`, so treating that as "nothing to delete" could mean
693
+ * silently leaving a decision in place that this call was supposed to remove.
694
+ * @throws {AgentPluginActivationsBusyError} The cross-process write lock could not be taken or was
695
+ * lost; nothing was written.
696
+ * @complexity O(p) in the recorded plugin count — the whole (small) file is rewritten, same as every
697
+ * other write in this file.
698
+ */
699
+ async function deleteAgentPluginActivation(required) {
700
+ const { workspaceRoot, pluginId } = required;
701
+ assertPluginId(pluginId);
702
+ await lockedActivationsWrite(workspaceRoot, async (lock) => {
703
+ const current = await readPluginsBagStrict(workspaceRoot);
704
+ if (!Object.hasOwn(current, pluginId))
705
+ return;
706
+ const remaining = { ...current };
707
+ delete remaining[pluginId];
708
+ await writeActivationsAtomically(workspaceRoot, remaining, lock);
709
+ });
710
+ }
711
+ function assertPluginId(pluginId) {
712
+ if (!SAFE_PLUGIN_ID_PATTERN.test(pluginId) || pluginId.length > 64) {
713
+ throw new Error(`agent-plugin activation: '${pluginId}' is not a valid Agent Plugin id`);
714
+ }
715
+ }
716
+ /**
717
+ * The tail of each workspace's in-flight write chain, keyed by resolved root so two spellings of one
718
+ * directory share a chain. An entry is dropped once its chain drains.
719
+ */
720
+ const pendingActivationWrites = new Map();
721
+ /**
722
+ * Runs one read-modify-write of a workspace's activations file only after every earlier one in this
723
+ * process has settled — see this file's header, "Concurrency", for the erased-decision race this
724
+ * closes and the cross-process race it does not.
725
+ *
726
+ * A write that throws does not jam the chain: the tail swallows the outcome, and the caller still
727
+ * receives its own rejection.
728
+ *
729
+ * @complexity O(1) bookkeeping, plus waiting for the writes queued ahead of it.
730
+ */
731
+ async function serializeActivationsWrite(workspaceRoot, write) {
732
+ const key = path.resolve(workspaceRoot);
733
+ const result = (pendingActivationWrites.get(key) ?? Promise.resolve()).then(write);
734
+ const tail = result.then(() => undefined, () => undefined);
735
+ pendingActivationWrites.set(key, tail);
736
+ try {
737
+ return await result;
738
+ }
739
+ finally {
740
+ if (pendingActivationWrites.get(key) === tail)
741
+ pendingActivationWrites.delete(key);
742
+ }
743
+ }
744
+ /**
745
+ * Composes the in-process chain with the cross-process lock, in that order — see this file's
746
+ * header, "Concurrency", ACROSS processes, for why the chain stays in front and why no exported
747
+ * function here may call another one from inside `write`.
748
+ *
749
+ * @throws {AgentPluginActivationsBusyError} The lock could not be taken within 15s, or was lost
750
+ * before `write` finished — either way nothing `write` did was written.
751
+ * @complexity `serializeActivationsWrite`'s queueing cost, plus one lock acquisition/release.
752
+ */
753
+ async function lockedActivationsWrite(workspaceRoot, write) {
754
+ return serializeActivationsWrite(workspaceRoot, async () => {
755
+ await mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
756
+ const lockPath = path.join(workspaceRoot, ACTIVATIONS_LOCK_FILENAME);
757
+ try {
758
+ return await withFileLock({ lockPath, run: write }, {
759
+ timeoutMs: 15_000, staleMs: 10_000, pollMs: 10,
760
+ filesystem: ports.filesystem, clock: ports.clock, monotonicMs: () => ports.clock.monotonicMs(),
761
+ process: ports.process,
762
+ token: () => ports.ids.newId(), hostname: () => ports.process.hostname({}),
763
+ sleep: ({ durationMs }) => ports.clock.sleep({ ms: durationMs }),
764
+ onStaleLockRemoved: ({ holder }) => warnStaleActivationsLockRemoved(lockPath, holder),
765
+ });
766
+ }
767
+ catch (error) {
768
+ throw toActivationsBusyError(lockPath, error);
769
+ }
770
+ });
771
+ }
772
+ /**
773
+ * Write-temp-then-`rename`, so a concurrent reader sees either the whole previous file or the whole
774
+ * new one and never a truncated JSON document. Same discipline `install.ts`'s `publish()` uses for
775
+ * a package tree, applied to a single small file.
776
+ *
777
+ * Takes the RAW `plugins` bag rather than a full {@link AgentPluginActivations} — every caller now
778
+ * builds one from a strict raw read (this file's header, "Writers never rewrite what they could not
779
+ * read"), so this is the one place that wraps it back in the `{ schemaVersion: 1, plugins }`
780
+ * envelope for serialization.
781
+ *
782
+ * Durability (R5, t91 2026-09-16): the temp file is fsync'd before the rename, and the workspace
783
+ * directory is best-effort fsync'd after — see this file's header and `syncDirectoryBestEffort`'s
784
+ * own doc for why a power loss must never leave a 0-byte `activations.json` behind.
785
+ *
786
+ * `lock.assertHeld()` runs immediately before the rename — the fail-closed check that stops a
787
+ * holder who stalled past the stale threshold from committing after another process already judged
788
+ * it stale (this file's header, "failure modes").
789
+ *
790
+ * @throws {FileLockLostError} `lock` no longer owns the cross-process lock; `lockedActivationsWrite`
791
+ * maps this to {@link AgentPluginActivationsBusyError}.
792
+ * @complexity One open, one write, one fsync, one rename, one best-effort directory fsync.
793
+ */
794
+ async function writeActivationsAtomically(workspaceRoot, plugins, lock) {
795
+ const finalPath = path.join(workspaceRoot, ACTIVATIONS_FILENAME);
796
+ const tempPath = `${finalPath}.tmp-${ports.process.pid}-${randomUUID()}`;
797
+ try {
798
+ /**
799
+ * Best-effort only — see this function's own doc.
800
+ */
801
+ const handle = await open(tempPath, "wx", 0o600);
802
+ try {
803
+ await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, plugins }, null, 2)}\n`, "utf8");
804
+ await handle.sync();
805
+ }
806
+ finally {
807
+ await handle.close();
808
+ }
809
+ await lock.assertHeld({});
810
+ await rename(tempPath, finalPath);
811
+ }
812
+ catch (error) {
813
+ await rm(tempPath, { force: true });
814
+ throw error;
815
+ }
816
+ await syncDirectoryBestEffort(workspaceRoot);
817
+ }
818
+ /**
819
+ * Best-effort fsync of a directory, so a rename that just landed in it survives a power loss rather
820
+ * than leaving a 0-byte or missing `activations.json` on the next boot (R5). Skipped entirely on
821
+ * win32, where a directory cannot be opened for this. Every failure is swallowed: the rename has
822
+ * already landed by the time this runs, so surfacing a failure here would tell the caller its write
823
+ * did not succeed when it did.
824
+ * @complexity One open, one fsync, one close.
825
+ */
826
+ async function syncDirectoryBestEffort(dir) {
827
+ if (ports.process.platform === "win32")
828
+ return;
829
+ try {
830
+ const handle = await open(dir, "r");
831
+ try {
832
+ await handle.sync();
833
+ }
834
+ finally {
835
+ await handle.close();
836
+ }
837
+ }
838
+ catch {
839
+ }
840
+ }
841
+ return { isAgentPluginActive, filterActiveAgentPlugins, readAgentPluginActivations, assertAgentPluginActivationsWritable, isAgentPluginRecordedAsBundled, resolveAgentPluginActivation, normalizeActivations, setAgentPluginActivation, recordBundledAgentPluginIfAbsent, enableBundledAgentPluginUnlessOperatorDisabled, deleteAgentPluginActivation };
842
+ }
843
+ const instances = new WeakMap();
844
+ /** Internal composition; one state/queue instance per injected host context. */
845
+ export function createActivationModule(ports) {
846
+ let instance = instances.get(ports);
847
+ if (!instance) {
848
+ instance = buildModule(ports);
849
+ instances.set(ports, instance);
850
+ }
851
+ return instance;
852
+ }
853
+ /** Bind activation effects once per host process. Required effects stay stable so every caller
854
+ * shares the same per-workspace write queue; discovery stays lenient, capability checks and writes
855
+ * refuse unreadable decisions. No network, package-layout or provisioning port is needed here.
856
+ * @complexity O(1) composition; reads/writes are O(p) in recorded plugins.
857
+ */
858
+ export function createAgentPluginActivations(required, optional = {}) {
859
+ const activation = createActivationModule(Object.keys(optional).length ? { ...required, ...optional } : required);
860
+ return {
861
+ setAgentPluginActivation: activation.setAgentPluginActivation,
862
+ recordBundledAgentPluginIfAbsent: activation.recordBundledAgentPluginIfAbsent,
863
+ enableBundledAgentPluginUnlessOperatorDisabled: activation.enableBundledAgentPluginUnlessOperatorDisabled,
864
+ deleteAgentPluginActivation: activation.deleteAgentPluginActivation,
865
+ isAgentPluginActive: (input, _optional = {}) => activation.isAgentPluginActive(input.activations, input.pluginId),
866
+ filterActiveAgentPlugins: (input, _optional = {}) => activation.filterActiveAgentPlugins(input.activations, input.items, item => input.pluginIdOf({ item })),
867
+ readAgentPluginActivations: (input, _optional = {}) => activation.readAgentPluginActivations(input.workspaceRoot),
868
+ assertAgentPluginActivationsWritable: (input, _optional = {}) => activation.assertAgentPluginActivationsWritable(input.workspaceRoot),
869
+ isAgentPluginRecordedAsBundled: (input, _optional = {}) => activation.isAgentPluginRecordedAsBundled(input.workspaceRoot, input.pluginId),
870
+ resolveAgentPluginActivation: (input, _optional = {}) => activation.resolveAgentPluginActivation(input.workspaceRoot, input.pluginId),
871
+ normalizeActivations: (input, _optional = {}) => activation.normalizeActivations(input.value),
872
+ };
873
+ }
874
+ //# sourceMappingURL=activation.js.map