@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.
- package/CHANGELOG.md +57 -0
- package/LICENSE +201 -0
- package/README.md +261 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/lifecycle/activation.d.ts +177 -0
- package/dist/lifecycle/activation.js +874 -0
- package/dist/lifecycle/bundled-digests.d.ts +27 -0
- package/dist/lifecycle/bundled-digests.js +149 -0
- package/dist/lifecycle/bundled-source-archive.d.ts +13 -0
- package/dist/lifecycle/bundled-source-archive.js +96 -0
- package/dist/lifecycle/capability-projection.d.ts +11 -0
- package/dist/lifecycle/capability-projection.js +60 -0
- package/dist/lifecycle/fetch-archive.d.ts +88 -0
- package/dist/lifecycle/fetch-archive.js +215 -0
- package/dist/lifecycle/index.d.ts +174 -0
- package/dist/lifecycle/index.js +94 -0
- package/dist/lifecycle/install-from-url.d.ts +78 -0
- package/dist/lifecycle/install-from-url.js +73 -0
- package/dist/lifecycle/install.d.ts +52 -0
- package/dist/lifecycle/install.js +286 -0
- package/dist/lifecycle/layout.d.ts +20 -0
- package/dist/lifecycle/layout.js +30 -0
- package/dist/lifecycle/manifest.d.ts +214 -0
- package/dist/lifecycle/manifest.js +409 -0
- package/dist/lifecycle/mcp-provisioning.d.ts +20 -0
- package/dist/lifecycle/mcp-provisioning.js +14 -0
- package/dist/lifecycle/node.d.ts +11 -0
- package/dist/lifecycle/node.js +34 -0
- package/dist/lifecycle/package-paths.d.ts +12 -0
- package/dist/lifecycle/package-paths.js +74 -0
- package/dist/lifecycle/ports.d.ts +81 -0
- package/dist/lifecycle/ports.js +2 -0
- package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +18 -0
- package/dist/lifecycle/resolve-agent-plugin-refs.js +143 -0
- package/dist/lifecycle/retire-bundled.d.ts +36 -0
- package/dist/lifecycle/retire-bundled.js +69 -0
- package/dist/lifecycle/search.d.ts +23 -0
- package/dist/lifecycle/search.js +50 -0
- package/dist/lifecycle/seed-bundled.d.ts +29 -0
- package/dist/lifecycle/seed-bundled.js +118 -0
- package/dist/lifecycle/set-enabled.d.ts +21 -0
- package/dist/lifecycle/set-enabled.js +39 -0
- package/dist/lifecycle/trusted-plugin-files.d.ts +29 -0
- package/dist/lifecycle/trusted-plugin-files.js +82 -0
- package/dist/lifecycle/types.d.ts +20 -0
- package/dist/lifecycle/types.js +2 -0
- package/dist/lifecycle/uninstall.d.ts +41 -0
- package/dist/lifecycle/uninstall.js +148 -0
- package/dist/lifecycle/yauzl-archive-reader.d.ts +62 -0
- package/dist/lifecycle/yauzl-archive-reader.js +73 -0
- package/dist/manifest.d.ts +68 -0
- package/dist/manifest.js +38 -0
- package/package.json +92 -0
- package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/ui-ux-design/mcp.json +4 -0
- package/ui-ux-design/plugin.json +5 -0
- package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
- package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
- package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
- package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
- package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
- package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
- package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
- package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
- package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
- package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
- package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
- package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
- package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
- package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
- package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
- package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
- package/ui-ux-design/skills/shadcn-ui/SKILL.md +326 -0
- package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/ui-ux-design/skills/ui-ux-design/SKILL.md +77 -0
- package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +72 -0
- package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +107 -0
- package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +65 -0
- package/ui-ux-design/skills/ui-ux-design/references/foundations.md +144 -0
- package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
- package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +102 -0
- package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
- package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +63 -0
- package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +78 -0
- package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +93 -0
- package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +123 -0
- package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +57 -0
- package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
- package/ui-ux-design/skills/web-compliance/SKILL.md +129 -0
- 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
|