@jini-ai/agent-plugins 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-10-04
4
+
5
+ - Layout B per-plugin state with a build-free `@jini-ai/agent-plugins/persistent-state` entry; migration completes after quarantining rejected entries.
6
+ - A directory-shaped ZIP entry with a symlink mode is still treated as a symlink.
7
+
3
8
  ## Unreleased — lifecycle consumer adoption
4
9
 
5
10
  - Expose narrow `createAgentPluginActivations(required, optional)` composition without
package/README.md CHANGED
@@ -259,3 +259,22 @@ For a host that needs only activation, use `createAgentPluginActivations` with
259
259
  `createNodeAgentPluginEffects({})` instead of supplying unused network/provisioning
260
260
  ports. Retain one returned object for the process lifetime. See `API.md` for the
261
261
  object-shaped reads, filters and optional legacy metadata reader.
262
+
263
+ ### Persistent plugin state (Layout B)
264
+
265
+ `@jini-ai/agent-plugins/persistent-state` is a build-free Node entry with declarations.
266
+ `pluginStatePaths({ workspaceRoot, pluginId })` gives one plugin's `package/sha256/`,
267
+ `memory/learned/`, `memory/notes/` and `data/` paths. `createPluginMemory` injects the
268
+ filesystem, the package containment primitive and a cross-process lock. Its plugin-facing
269
+ `learned` capability cannot select another id or write user notes. Defaults: 1 MiB learned,
270
+ 16 KiB notes, 128 files per tier; callers may lower these caps.
271
+
272
+ `migratePluginLayout` moves legacy packages, memory and PLUGIN_DATA, preserving conflicts
273
+ and malformed installs for human repair. It records version 2 only after success and repairs
274
+ frozen package roots after interrupted moves. Lock files use workspace staging scratch.
275
+ Uninstall keeps persistent state by default; deleting it requires a matching confirmed preview.
276
+ The reserved workspace directory ids `staging`, `packages`, `memory`, and `data` are refused
277
+ as plugin ids so package state cannot overlap migration inputs or installation scratch.
278
+
279
+ The `dev.tovu.memory` proposal is consumer policy and is not implemented by this neutral entry.
280
+ No version bump or publishing accompanies this workspace implementation.
@@ -137,8 +137,18 @@ export declare function createAgentPluginLifecycle(required: AgentPluginLifecycl
137
137
  }, optional?: {
138
138
  readonly deliveryMode?: AgentPluginDeliveryMode;
139
139
  }) => Promise<import("./resolve-agent-plugin-refs.js").ResolveAgentPluginRefsResult>;
140
+ pluginMemory: (input: {
141
+ readonly workspaceId: string;
142
+ readonly pluginId: string;
143
+ }) => import("../../persistent-state.js").PluginMemory;
144
+ migratePluginLayout: (input: {
145
+ readonly workspaceId: string;
146
+ }) => Promise<{
147
+ complete: boolean;
148
+ moved: number;
149
+ }>;
140
150
  listInstalledPlugins: (input: {
141
- readonly packagesDir: string;
151
+ readonly workspaceRoot: string;
142
152
  }) => Promise<readonly import("./types.js").InstalledAgentPlugin[]>;
143
153
  isInstalledDigestPresent: (input: {
144
154
  readonly packagesDir: string;
@@ -1,3 +1,4 @@
1
+ import { createPersistentStateModule } from "./persistent-state.js";
1
2
  import { fetchAgentPluginArchive, maxAgentPluginArchiveBytes } from './fetch-archive.js';
2
3
  import { createSeedBundledModule } from './seed-bundled.js';
3
4
  import { createRetireBundledModule } from './retire-bundled.js';
@@ -80,7 +81,13 @@ export function createAgentPluginLifecycle(required, optional = {}) {
80
81
  readInstalledMcpServerIds: (input) => capabilities.readInstalledMcpServerIds(input.packageRoot),
81
82
  readInstalledMcpServers: (input) => capabilities.readInstalledMcpServers(input.packageRoot),
82
83
  resolveAgentPluginRefs: (input, optional = {}) => refs.resolveAgentPluginRefs(input.pluginRefIds, input.workspaceLayout, optional.deliveryMode ?? context.deliveryMode),
83
- listInstalledPlugins: (input) => refs.listInstalledPlugins(input.packagesDir),
84
+ pluginMemory: (input) => createPersistentStateModule(context).memory({
85
+ workspaceRoot: context.layout.forWorkspace({ workspaceId: input.workspaceId }).root, pluginId: input.pluginId,
86
+ }),
87
+ migratePluginLayout: (input) => createPersistentStateModule(context).migrate({
88
+ workspaceRoot: context.layout.forWorkspace({ workspaceId: input.workspaceId }).root,
89
+ }),
90
+ listInstalledPlugins: (input) => refs.listInstalledPlugins(input.workspaceRoot),
84
91
  isInstalledDigestPresent: (input) => refs.isInstalledDigestPresent(input.packagesDir, input.archiveDigest),
85
92
  rankInstalledAgentPlugins: (input) => search.rankInstalledAgentPlugins(input.query, input.candidates, input.limit),
86
93
  readTrustedPluginFile: (input) => trusted.readTrustedPluginFile(input.plugin, input.filename),
@@ -1,3 +1,4 @@
1
+ import { createPersistentStateModule } from "./persistent-state.js";
1
2
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
2
3
  import { createHash } from "node:crypto";
3
4
  import { constants } from "node:fs";
@@ -14,6 +15,7 @@ export class AgentPluginInstallError extends Error {
14
15
  }
15
16
  }
16
17
  function buildModule(ports) {
18
+ const persistentState = createPersistentStateModule(ports);
17
19
  const chmod = ports.filesystem.chmod.bind(ports.filesystem);
18
20
  const mkdir = ports.filesystem.mkdir.bind(ports.filesystem);
19
21
  const mkdtemp = ports.filesystem.mkdtemp.bind(ports.filesystem);
@@ -45,12 +47,8 @@ function buildModule(ports) {
45
47
  if (!SHA256_HEX_PATTERN.test(expectedSha256) || digest !== expectedSha256.toLowerCase()) {
46
48
  throw new AgentPluginInstallError({ code: "DIGEST_MISMATCH", message: `archive SHA-256 '${digest}' does not match the expected '${expectedSha256}' — refusing to extract unverified bytes` });
47
49
  }
48
- await mkdir(workspaceLayout.packages, { recursive: true, mode: 0o700 });
49
- const finalRoot = path.join(workspaceLayout.packages, digest);
50
- const alreadyPublished = await isRealDirectory(finalRoot);
51
- if (alreadyPublished) {
52
- return indexInstalledRoot(finalRoot, digest);
53
- }
50
+ await mkdir(workspaceLayout.root, { recursive: true, mode: 0o700 });
51
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: "staging" });
54
52
  await mkdir(workspaceLayout.staging, { recursive: true, mode: 0o700 });
55
53
  await assertEntryCountWithinCap(archiveReader.entries({ archive }));
56
54
  const transactionRoot = await mkdtemp(path.join(workspaceLayout.staging, "install-"));
@@ -59,9 +57,25 @@ function buildModule(ports) {
59
57
  try {
60
58
  const executablePaths = await extractEntries(archiveReader.entries({ archive }), extractionRoot);
61
59
  const indexed = await indexInstalledRoot(extractionRoot, digest);
62
- await publish(extractionRoot, finalRoot);
63
- await freezeTree(finalRoot, executablePaths);
64
- return { ...indexed, packageRoot: finalRoot };
60
+ return await persistentState.withPluginLock({ workspaceRoot: workspaceLayout.root, pluginId: indexed.pluginId, run: async () => {
61
+ // The manifest identifies the plugin only after bounded staging extraction (Layout B).
62
+ const pluginId = indexed.pluginId;
63
+ const packagesDir = workspaceLayout.pluginPackagesDir({ pluginId });
64
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, packagesDir) });
65
+ await mkdir(packagesDir, { recursive: true, mode: 0o700 });
66
+ const finalRoot = await assertContainedOnDisk(packagesDir, digest);
67
+ for (const directory of [workspaceLayout.pluginDataDir({ pluginId }),
68
+ workspaceLayout.pluginMemoryDir({ pluginId, kind: 'learned' }),
69
+ workspaceLayout.pluginMemoryDir({ pluginId, kind: 'notes' })]) {
70
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, directory) });
71
+ await mkdir(directory, { recursive: true, mode: 0o700 });
72
+ }
73
+ if (await isRealDirectory(finalRoot))
74
+ return indexInstalledRoot(finalRoot, digest);
75
+ await publish(extractionRoot, finalRoot);
76
+ await freezeTree(finalRoot, executablePaths);
77
+ return { ...indexed, packageRoot: finalRoot };
78
+ } });
65
79
  }
66
80
  finally {
67
81
  await rm(transactionRoot, { recursive: true, force: true });
@@ -2,6 +2,16 @@ export interface AgentPluginWorkspaceLayout {
2
2
  readonly root: string;
3
3
  readonly packages: string;
4
4
  readonly staging: string;
5
+ pluginRootDir(required: {
6
+ readonly pluginId: string;
7
+ }, optional?: Record<string, never>): string;
8
+ pluginPackagesDir(required: {
9
+ readonly pluginId: string;
10
+ }, optional?: Record<string, never>): string;
11
+ pluginMemoryDir(required: {
12
+ readonly pluginId: string;
13
+ readonly kind: 'learned' | 'notes';
14
+ }, optional?: Record<string, never>): string;
5
15
  pluginDataDir(required: {
6
16
  readonly pluginId: string;
7
17
  }): string;
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { pluginStatePaths } from '../../persistent-state.js';
2
3
  /** Explicit root only; site-root and environment resolution belong to the consumer. */
3
4
  export function createAgentPluginLayout(required) {
4
5
  if (!path.isAbsolute(required.root))
@@ -14,9 +15,16 @@ export function createAgentPluginLayout(required) {
14
15
  root: workspaceRoot,
15
16
  packages: path.join(workspaceRoot, 'packages', 'sha256'),
16
17
  staging: path.join(workspaceRoot, 'staging'),
18
+ pluginRootDir: ({ pluginId }, _optional = {}) => pluginStatePaths({ workspaceRoot, pluginId }).root,
19
+ pluginPackagesDir: ({ pluginId }, _optional = {}) => pluginStatePaths({ workspaceRoot, pluginId }).packages,
20
+ pluginMemoryDir: ({ pluginId, kind }, _optional = {}) => {
21
+ if (kind !== 'learned' && kind !== 'notes')
22
+ throw new Error('Invalid memory kind');
23
+ return pluginStatePaths({ workspaceRoot, pluginId })[kind];
24
+ },
17
25
  pluginDataDir({ pluginId }) {
18
26
  assertSafeSegment(pluginId);
19
- return path.join(workspaceRoot, 'data', pluginId);
27
+ return pluginStatePaths({ workspaceRoot, pluginId }).data;
20
28
  },
21
29
  };
22
30
  },
@@ -0,0 +1,24 @@
1
+ import type { AgentPluginLifecyclePorts } from './ports.js';
2
+ /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
3
+ export declare function createPersistentStateModule(ports: AgentPluginLifecyclePorts, _optional?: Record<string, never>): {
4
+ withPluginLock: <T>(required: {
5
+ workspaceRoot: string;
6
+ pluginId: string;
7
+ run(): Promise<T>;
8
+ }, optional?: {}) => Promise<T>;
9
+ memory: (required: {
10
+ workspaceRoot: string;
11
+ pluginId: string;
12
+ }, optional?: {}) => import("../../persistent-state.js").PluginMemory;
13
+ assertOwnedPath: (required: {
14
+ workspaceRoot: string;
15
+ entryPath: string;
16
+ }, optional?: {}) => Promise<string>;
17
+ migrate: (required: {
18
+ workspaceRoot: string;
19
+ }, optional?: {}) => Promise<{
20
+ complete: boolean;
21
+ moved: number;
22
+ }>;
23
+ };
24
+ //# sourceMappingURL=persistent-state.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../persistent-state.js';
2
+ import { withFileLock } from '@jini-ai/platform/fs/file-lock';
3
+ import { createPackagePathsModule } from './package-paths.js';
4
+ import { parseAgentPluginManifest } from './manifest.js';
5
+ /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
6
+ export function createPersistentStateModule(ports, _optional = {}) {
7
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
8
+ const effects = {
9
+ filesystem: ports.filesystem,
10
+ contain: ({ root, entryPath }) => assertContainedOnDisk(root, entryPath),
11
+ withLock: ({ lockPath, run }) => withFileLock({ lockPath, run }, {
12
+ filesystem: ports.filesystem, clock: ports.clock, process: ports.process,
13
+ hostname: () => ports.process.hostname({}), token: () => ports.ids.newId(),
14
+ monotonicMs: () => ports.clock.monotonicMs(), sleep: ({ durationMs }) => ports.clock.sleep({ ms: durationMs }),
15
+ staleMs: Infinity, createParent: true,
16
+ }),
17
+ };
18
+ return {
19
+ withPluginLock: (required, optional = {}) => withPluginStateLock({ ...effects, ...required }, optional),
20
+ memory: (required, optional = {}) => createPluginMemory({ ...effects, ...required }, optional),
21
+ assertOwnedPath: (required, optional = {}) => assertPluginStatePath({ ...effects, ...required }, optional),
22
+ migrate: (required, optional = {}) => migratePluginLayout({ ...effects, ...required,
23
+ parsePluginId: ({ value }) => {
24
+ const parsed = parseAgentPluginManifest({ value });
25
+ if (!parsed.ok)
26
+ throw new Error(parsed.errors.join('; '));
27
+ return parsed.manifest.name;
28
+ }, ...(ports.onEvent ? { onEvent: ports.onEvent } : {}),
29
+ }, optional),
30
+ };
31
+ }
32
+ //# sourceMappingURL=persistent-state.js.map
@@ -4,7 +4,7 @@ import type { AgentPluginLayoutPort } from './layout.js';
4
4
  import type { AgentPluginDeliveryMode, InstalledAgentPlugin } from './types.js';
5
5
  /** Native Node filesystem ABI, narrowed to the operations used by lifecycle.
6
6
  * Inject instrumented fs/promises effects directly; native handles are never rewrapped. */
7
- export type FilesystemPort = Pick<typeof import('node:fs/promises'), 'chmod' | 'mkdir' | 'mkdtemp' | 'open' | 'readdir' | 'readFile' | 'rename' | 'rm' | 'stat' | 'realpath' | 'unlink'>;
7
+ export type FilesystemPort = Pick<typeof import('node:fs/promises'), 'chmod' | 'mkdir' | 'mkdtemp' | 'open' | 'readdir' | 'readFile' | 'rename' | 'rm' | 'rmdir' | 'stat' | 'realpath' | 'unlink'>;
8
8
  export type AgentPluginFetchPort = (required: {
9
9
  readonly url: string;
10
10
  }, optional?: RequestInit) => Promise<Response>;
@@ -12,7 +12,7 @@ export type ResolveAgentPluginRefsResult = {
12
12
  /** Internal composition; one state/queue instance per injected host context. */
13
13
  export declare function createResolveAgentPluginRefsModule(ports: AgentPluginLifecyclePorts): {
14
14
  resolveAgentPluginRefs: (pluginRefIds: readonly string[], workspaceLayout: Pick<AgentPluginWorkspaceLayout, "packages" | "root">, deliveryMode?: AgentPluginDeliveryMode) => Promise<ResolveAgentPluginRefsResult>;
15
- listInstalledPlugins: (packagesDir: string) => Promise<readonly InstalledAgentPlugin[]>;
15
+ listInstalledPlugins: (workspaceRoot: string) => Promise<readonly InstalledAgentPlugin[]>;
16
16
  isInstalledDigestPresent: (packagesDir: string, archiveDigest: string) => Promise<boolean>;
17
17
  };
18
18
  //# sourceMappingURL=resolve-agent-plugin-refs.d.ts.map
@@ -1,5 +1,7 @@
1
+ import { createPersistentStateModule } from "./persistent-state.js";
1
2
  /** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
2
3
  import path from "node:path";
4
+ import { createPackagePathsModule } from "./package-paths.js";
3
5
  import {} from "./activation.js";
4
6
  import { createActivationModule } from "./activation.js";
5
7
  import {} from "./bundled-digests.js";
@@ -7,6 +9,8 @@ import { createBundledDigestsModule } from "./bundled-digests.js";
7
9
  import { createInstallModule } from "./install.js";
8
10
  import { createCapabilityProjectionModule } from "./capability-projection.js";
9
11
  function buildModule(ports) {
12
+ const persistentState = createPersistentStateModule(ports);
13
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
10
14
  const readdir = ports.filesystem.readdir.bind(ports.filesystem);
11
15
  const stat = ports.filesystem.stat.bind(ports.filesystem);
12
16
  const { resolveAgentPluginActivation } = createActivationModule(ports);
@@ -24,7 +28,7 @@ function buildModule(ports) {
24
28
  const refusal = activationRefusal(pluginRefId, await resolveAgentPluginActivation(workspaceLayout.root, pluginRefId));
25
29
  if (refusal !== undefined)
26
30
  return { ok: false, reason: refusal };
27
- const resolved = await resolveOnePluginRef(pluginRefId, workspaceLayout.packages, deliveryMode, bundledDigests);
31
+ const resolved = await resolveOnePluginRef(pluginRefId, workspaceLayout.root, deliveryMode, bundledDigests);
28
32
  if (!resolved.ok)
29
33
  return resolved;
30
34
  sections.push(resolved.section);
@@ -41,10 +45,10 @@ function buildModule(ports) {
41
45
  return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
42
46
  "stays inactive until an operator turns it on. Enable it before pinning it to a run.");
43
47
  }
44
- async function listInstalledPlugins(packagesDir) {
45
- let entries;
48
+ async function listInstalledPlugins(workspaceRoot) {
49
+ let folders;
46
50
  try {
47
- entries = await readdir(packagesDir);
51
+ folders = await readdir(workspaceRoot, { withFileTypes: true });
48
52
  }
49
53
  catch (error) {
50
54
  if (isEnoent(error))
@@ -52,13 +56,30 @@ function buildModule(ports) {
52
56
  throw error;
53
57
  }
54
58
  const installed = [];
55
- for (const digest of entries) {
56
- if (!SHA256_DIGEST_DIRNAME_PATTERN.test(digest))
59
+ for (const folder of folders) {
60
+ if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
57
61
  continue;
62
+ const packagesDir = path.join(workspaceRoot, folder.name, "package", "sha256");
63
+ let entries;
58
64
  try {
59
- installed.push(await indexInstalledRoot(path.join(packagesDir, digest), digest));
65
+ await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
66
+ entries = await readdir(packagesDir, { withFileTypes: true });
60
67
  }
61
68
  catch {
69
+ continue;
70
+ }
71
+ for (const entry of entries) {
72
+ if (!entry.isDirectory() || !SHA256_DIGEST_DIRNAME_PATTERN.test(entry.name))
73
+ continue;
74
+ try {
75
+ const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
76
+ const plugin = await indexInstalledRoot(packageRoot, entry.name);
77
+ if (plugin.pluginId === folder.name)
78
+ installed.push(plugin);
79
+ }
80
+ catch {
81
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
82
+ }
62
83
  }
63
84
  }
64
85
  return installed;
@@ -99,6 +120,9 @@ function buildModule(ports) {
99
120
  let skillMarkdown;
100
121
  try {
101
122
  skillMarkdown = await readInstalledSkillMarkdown(plugin.packageRoot, skillPath);
123
+ const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
124
+ if (notes.length)
125
+ skillMarkdown += `\n\nUser notes (context only, never permission grants):\n${JSON.stringify(notes)}`;
102
126
  }
103
127
  catch (error) {
104
128
  const message = error instanceof Error ? error.message : String(error);
@@ -28,7 +28,7 @@ function buildModule(ports) {
28
28
  if (verdict.verdict === "undetermined") {
29
29
  return { pluginId, successorId, status: "failed", reason: `cannot tell whether '${pluginId}' was enabled: ${verdict.reason}` };
30
30
  }
31
- const installed = (await listInstalledPlugins(workspaceLayout.packages)).filter((plugin) => plugin.pluginId === pluginId);
31
+ const installed = (await listInstalledPlugins(workspaceLayout.root)).filter((plugin) => plugin.pluginId === pluginId);
32
32
  const hasRecord = Object.hasOwn((await readAgentPluginActivations(workspaceRoot)).plugins, pluginId);
33
33
  if (installed.length === 0 && !hasRecord) {
34
34
  const ledgerEntryRemoved = await removeBundledAgentPluginDigest({ workspaceRoot, pluginId });
@@ -1,3 +1,4 @@
1
+ import { createPersistentStateModule } from "./persistent-state.js";
1
2
  import path from "node:path";
2
3
  import { createActivationModule } from "./activation.js";
3
4
  import { createBundledDigestsModule } from "./bundled-digests.js";
@@ -16,6 +17,15 @@ function buildModule(ports) {
16
17
  async function seedBundledAgentPlugins(required) {
17
18
  const { layout, workspaceId, sourceRoot } = required;
18
19
  const pluginDirNames = await listBundledPluginDirs(sourceRoot);
20
+ try {
21
+ const migration = await createPersistentStateModule(ports).migrate({ workspaceRoot: layout.forWorkspace({ workspaceId }).root });
22
+ if (!migration.complete)
23
+ throw new Error('Legacy plugin migration incomplete; see migration events');
24
+ }
25
+ catch (error) {
26
+ const reason = error instanceof Error ? error.message : String(error);
27
+ return { sourceRoot, outcomes: pluginDirNames.map(pluginId => ({ pluginId, status: 'failed', reason })), retirements: [] };
28
+ }
19
29
  const refusal = await activationsRefusal(layout, workspaceId);
20
30
  if (refusal !== undefined) {
21
31
  return {
@@ -12,7 +12,7 @@ function buildModule(ports) {
12
12
  const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
13
13
  async function setAgentPluginEnabled(input) {
14
14
  const workspaceLayout = ports.layout.forWorkspace({ workspaceId: input.workspaceId });
15
- const installed = await listInstalledPlugins(workspaceLayout.packages);
15
+ const installed = await listInstalledPlugins(workspaceLayout.root);
16
16
  if (!installed.some((plugin) => plugin.pluginId === input.pluginId)) {
17
17
  throw new AgentPluginNotInstalledError({ pluginId: input.pluginId });
18
18
  }
@@ -15,7 +15,7 @@ function buildModule(ports) {
15
15
  async function findTrustedPluginPackages(query) {
16
16
  const layout = ports.layout.forWorkspace({ workspaceId: query.workspaceId });
17
17
  const bundled = await readBundledAgentPluginDigests(layout.root);
18
- const preferred = preferBundledAgentPluginDigests(await listInstalledPlugins(layout.packages), bundled);
18
+ const preferred = preferBundledAgentPluginDigests(await listInstalledPlugins(layout.root), bundled);
19
19
  const installed = query.orderByPluginId ? [...preferred].sort((a, b) => a.pluginId.localeCompare(b.pluginId)) : preferred;
20
20
  const verdicts = [];
21
21
  for (const plugin of installed) {
@@ -22,6 +22,7 @@ export interface UninstallAgentPluginRequired {
22
22
  }
23
23
  export interface UninstallAgentPluginOptional {
24
24
  readonly confirmedPreview?: (AgentPluginUninstallPreview) | undefined;
25
+ readonly deleteMemory?: boolean | undefined;
25
26
  readonly retiredBundled?: (boolean) | undefined;
26
27
  }
27
28
  export interface UninstallAgentPluginResult {
@@ -1,4 +1,6 @@
1
+ import { createPersistentStateModule } from "./persistent-state.js";
1
2
  import path from "node:path";
3
+ import { createPackagePathsModule } from "./package-paths.js";
2
4
  import { createActivationModule } from "./activation.js";
3
5
  import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
4
6
  export class AgentPluginNotFoundError extends Error {
@@ -12,6 +14,8 @@ export class AgentPluginChangedSincePreviewError extends Error {
12
14
  }
13
15
  function buildModule(ports) {
14
16
  const randomUUID = () => ports.ids.newId();
17
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
18
+ const rmdir = ports.filesystem.rmdir.bind(ports.filesystem);
15
19
  const chmod = ports.filesystem.chmod.bind(ports.filesystem);
16
20
  const readdir = ports.filesystem.readdir.bind(ports.filesystem);
17
21
  const rename = ports.filesystem.rename.bind(ports.filesystem);
@@ -26,8 +30,16 @@ function buildModule(ports) {
26
30
  return { pluginId: required.pluginId, versions, archiveDigests: matches.map((plugin) => plugin.archiveDigest) };
27
31
  }
28
32
  async function uninstallAgentPlugin(required, optional = {}) {
33
+ const workspaceRoot = required.layout.forWorkspace({ workspaceId: required.workspaceId }).root;
34
+ return createPersistentStateModule(ports).withPluginLock({ workspaceRoot, pluginId: required.pluginId,
35
+ run: () => uninstallAgentPluginUnlocked(required, optional) });
36
+ }
37
+ async function uninstallAgentPluginUnlocked(required, optional = {}) {
29
38
  const { workspaceRoot, packagesDir, matches } = await resolveUninstallTargets(required, optional.retiredBundled === true);
30
39
  assertUnchangedSincePreview(required.pluginId, optional.confirmedPreview, matches);
40
+ if (optional.deleteMemory && (!optional.confirmedPreview || optional.confirmedPreview.pluginId !== required.pluginId)) {
41
+ throw new Error('Deleting persistent plugin state requires a confirmed preview');
42
+ }
31
43
  const staged = [];
32
44
  try {
33
45
  for (const plugin of matches) {
@@ -41,6 +53,22 @@ function buildModule(ports) {
41
53
  for (const tree of staged) {
42
54
  await removeFrozenPackageTree(tree.quarantined);
43
55
  }
56
+ for (const directory of [packagesDir, path.dirname(packagesDir)]) {
57
+ try {
58
+ await rmdir(directory);
59
+ }
60
+ catch (error) {
61
+ if (!error || typeof error !== 'object' || !('code' in error) || !['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(error.code)))
62
+ throw error;
63
+ }
64
+ }
65
+ if (optional.deleteMemory) {
66
+ const pluginRoot = required.layout.forWorkspace({ workspaceId: required.workspaceId }).pluginRootDir({ pluginId: required.pluginId });
67
+ await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, pluginRoot));
68
+ if ((await listInstalledPlugins(workspaceRoot)).some(plugin => plugin.pluginId === required.pluginId))
69
+ throw new Error('Plugin was reinstalled; persistent state was kept');
70
+ await rm(pluginRoot, { recursive: true, force: true });
71
+ }
44
72
  return { pluginId: required.pluginId, removedDigests: matches.map((plugin) => plugin.archiveDigest) };
45
73
  }
46
74
  function assertUnchangedSincePreview(pluginId, confirmedPreview, matches) {
@@ -86,7 +114,7 @@ function buildModule(ports) {
86
114
  async function resolveUninstallTargets(required, retiredBundled = false) {
87
115
  const { layout, workspaceId, pluginId } = required;
88
116
  const workspaceLayout = layout.forWorkspace({ workspaceId });
89
- const installed = await listInstalledPlugins(workspaceLayout.packages);
117
+ const installed = await listInstalledPlugins(workspaceLayout.root);
90
118
  const matches = installed.filter((plugin) => plugin.pluginId === pluginId);
91
119
  if (matches.length === 0) {
92
120
  throw new AgentPluginNotFoundError({ message: `Agent Plugin '${pluginId}' is not installed in this workspace — nothing to uninstall` });
@@ -97,7 +125,7 @@ function buildModule(ports) {
97
125
  if ((await resolveAgentPluginActivation(workspaceLayout.root, pluginId)).verdict === "undetermined") {
98
126
  throw new AgentPluginNotUninstallableError({ message: malformedEntryRefusalMessage(pluginId) });
99
127
  }
100
- return { workspaceRoot: workspaceLayout.root, packagesDir: workspaceLayout.packages, matches };
128
+ return { workspaceRoot: workspaceLayout.root, packagesDir: workspaceLayout.pluginPackagesDir({ pluginId }), matches };
101
129
  }
102
130
  function bundledRefusalMessage(pluginId) {
103
131
  return (`Agent Plugin '${pluginId}' is bundled with ${ports.productName} and cannot be uninstalled — it is re-seeded on every boot ` +
@@ -30,14 +30,15 @@ export function createYauzlAgentPluginArchiveReader({ yauzl }) {
30
30
  * module's header. `install.ts` rejects every symlink entry unconditionally.
31
31
  */
32
32
  const unixMode = readUnixMode(entry);
33
- if (entry.fileName.endsWith("/")) {
34
- yield { kind: "directory", entryPath: entry.fileName };
35
- continue;
36
- }
37
33
  if (unixMode !== null && (unixMode & S_IFMT) === S_IFLNK) {
38
34
  yield { kind: "symlink", entryPath: entry.fileName };
39
35
  continue;
40
36
  }
37
+ // File mode defines links even when a hostile filename masquerades as a directory.
38
+ if (entry.fileName.endsWith("/")) {
39
+ yield { kind: "directory", entryPath: entry.fileName };
40
+ continue;
41
+ }
41
42
  yield {
42
43
  kind: "file",
43
44
  entryPath: entry.fileName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/agent-plugins",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Support for Agent Plugins, the open, vendor-neutral spec (v1.0.0, published 2026-08-06, Technical Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel) for bundling Agent Skills and MCP servers into one portable directory: PluginManifest/McpManifest types + structural validators on the package root, plus the bundled plugins themselves, each on a subpath named for itself (./ui-ux-design). Extracted 2026-08-18 from @jini-ai/plugins, which now only reserves the unrelated, unimplemented ./host format.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,7 +18,8 @@
18
18
  "./lifecycle": "node",
19
19
  "./lifecycle/node": "node",
20
20
  "./lifecycle/yauzl": "node",
21
- "./manifest": "universal"
21
+ "./manifest": "universal",
22
+ "./persistent-state": "node"
22
23
  }
23
24
  },
24
25
  "type": "module",
@@ -49,6 +50,11 @@
49
50
  "types": "./dist/lifecycle/manifest.d.ts",
50
51
  "import": "./dist/lifecycle/manifest.js",
51
52
  "default": "./dist/lifecycle/manifest.js"
53
+ },
54
+ "./persistent-state": {
55
+ "types": "./persistent-state.d.ts",
56
+ "import": "./persistent-state.js",
57
+ "default": "./persistent-state.js"
52
58
  }
53
59
  },
54
60
  "files": [
@@ -61,7 +67,9 @@
61
67
  "!dist/**/*.test.d.ts",
62
68
  "!dist/**/*.test.js.map",
63
69
  "!dist/**/*.test.d.ts.map",
64
- "!dist/**/__tests__/**"
70
+ "!dist/**/__tests__/**",
71
+ "persistent-state.js",
72
+ "persistent-state.d.ts"
65
73
  ],
66
74
  "publishConfig": {
67
75
  "access": "public",
@@ -0,0 +1,25 @@
1
+ export type PluginMemoryKind = 'learned' | 'notes';
2
+ export interface PluginStatePaths { root: string; packages: string; data: string; learned: string; notes: string; }
3
+ export const DEFAULT_PLUGIN_MEMORY_LIMITS: Readonly<{ learned: number; notes: number; files: number }>;
4
+ export interface PluginStateEffects {
5
+ filesystem: Pick<typeof import('node:fs/promises'), 'mkdir' | 'rmdir' | 'readdir' | 'open' | 'readFile' | 'rename' | 'rm' | 'stat' | 'chmod' | 'realpath'>;
6
+ contain(required: { root: string; entryPath: string }): Promise<string>;
7
+ withLock<T>(required: { lockPath: string; run(): Promise<T> }): Promise<T>;
8
+ }
9
+ export interface PluginMemoryFile { relativePath: string; text: string; }
10
+ export interface PluginMemory {
11
+ list(required: { kind: PluginMemoryKind }, optional?: {}): Promise<PluginMemoryFile[]>;
12
+ read(required: { kind: PluginMemoryKind; entryPath: string }, optional?: {}): Promise<string>;
13
+ writeNote(required: { entryPath: string; text: string }, optional?: {}): Promise<{ relativePath: string; bytes: number }>;
14
+ learned: {
15
+ read(required: { entryPath: string }, optional?: {}): Promise<string>;
16
+ write(required: { entryPath: string; text: string }, optional?: {}): Promise<{ relativePath: string; bytes: number }>;
17
+ };
18
+ }
19
+ export function pluginStatePaths(required: { workspaceRoot: string; pluginId: string }, optional?: {}): PluginStatePaths;
20
+ export function createPluginMemory(required: PluginStateEffects & { workspaceRoot: string; pluginId: string }, optional?: { limits?: Partial<typeof DEFAULT_PLUGIN_MEMORY_LIMITS> }): PluginMemory;
21
+ export function migratePluginLayout(required: PluginStateEffects & { workspaceRoot: string; parsePluginId(required: { value: unknown }): string; onEvent?(required: { event: string; message: string }): void }, optional?: {}): Promise<{ complete: boolean; moved: number }>;
22
+
23
+ export function assertPluginStatePath(required: Pick<PluginStateEffects, "filesystem" | "contain"> & { workspaceRoot: string; entryPath: string }, optional?: {}): Promise<string>;
24
+
25
+ export function withPluginStateLock<T>(required: PluginStateEffects & { workspaceRoot: string; pluginId: string; run(): Promise<T> }, optional?: {}): Promise<T>;
@@ -0,0 +1,260 @@
1
+ /** Build-free Node entry: effects and policy belong to the host; no product namespaces here. */
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const SAFE_ID = /^[a-z0-9]+(?:[-.][a-z0-9]+)*$/;
6
+ const DIGEST = /^[a-f0-9]{64}$/;
7
+ export const DEFAULT_PLUGIN_MEMORY_LIMITS = Object.freeze({ learned: 1024 * 1024, notes: 16 * 1024, files: 128 });
8
+ export function pluginStatePaths({ workspaceRoot, pluginId }, _optional = {}) {
9
+ if (!path.isAbsolute(workspaceRoot) || !SAFE_ID.test(pluginId) || pluginId.length > 64 || ['staging', 'packages', 'memory', 'data'].includes(pluginId)) throw new Error('Invalid plugin state root or id');
10
+ const root = path.join(workspaceRoot, pluginId);
11
+ return { root, packages: path.join(root, 'package', 'sha256'), data: path.join(root, 'data'),
12
+ learned: path.join(root, 'memory', 'learned'), notes: path.join(root, 'memory', 'notes') };
13
+ }
14
+ /** Containment plus ownership: a sibling plugin inside the workspace is still outside this one. */
15
+ export async function assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath }, _optional = {}) {
16
+ const absolute = await contain({ root: workspaceRoot, entryPath });
17
+ const realWorkspace = await fs.realpath(workspaceRoot);
18
+ const relative = path.relative(realWorkspace, absolute);
19
+ let current = realWorkspace;
20
+ for (const segment of relative.split(path.sep)) {
21
+ current = path.join(current, segment);
22
+ try { if (await fs.realpath(current) !== current) throw new Error('Plugin state ancestors must not be symlinks'); }
23
+ catch (error) { if (code(error) === 'ENOENT') break; throw error; }
24
+ }
25
+ return absolute;
26
+ }
27
+ function code(error) { return error && typeof error === 'object' ? error.code : undefined; }
28
+
29
+ /** Lock files are workspace scratch so uninstall cannot delete a lock another process holds. */
30
+ export async function withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run }, _optional = {}) {
31
+ pluginStatePaths({ workspaceRoot, pluginId });
32
+ await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
33
+ const lockPath = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: `staging/.plugin-state-${pluginId}.lock` });
34
+ return withLock({ lockPath, run });
35
+ }
36
+ /** Each instance is bound to one plugin. No read/write method accepts an id or arbitrary root. */
37
+ export function createPluginMemory({ filesystem: fs, contain, workspaceRoot, pluginId, withLock }, optional = {}) {
38
+ const paths = pluginStatePaths({ workspaceRoot, pluginId });
39
+ const limits = { ...DEFAULT_PLUGIN_MEMORY_LIMITS, ...optional.limits };
40
+ for (const kind of ['learned', 'notes', 'files']) {
41
+ if (!Number.isSafeInteger(limits[kind]) || limits[kind] <= 0 || limits[kind] > DEFAULT_PLUGIN_MEMORY_LIMITS[kind]) throw new Error('Memory caps must be positive and cannot exceed host ceilings');
42
+ }
43
+ function checkKind(kind) { if (kind !== 'learned' && kind !== 'notes') throw new Error('Invalid memory kind'); }
44
+ async function root(kind, create = false) {
45
+ checkKind(kind);
46
+ if (create) await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
47
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, paths[kind]) });
48
+ if (create) await fs.mkdir(paths[kind], { recursive: true, mode: 0o700 });
49
+ return paths[kind];
50
+ }
51
+ async function readText(filename, cap) {
52
+ // A bounded read protects against a concurrent file growing after stat. Invalid UTF-8 is refused.
53
+ const handle = await fs.open(filename, 'r');
54
+ try {
55
+ if (!(await handle.stat()).isFile()) throw new Error('Memory must contain regular UTF-8 files');
56
+ const bytes = Buffer.alloc(cap + 1);
57
+ let size = 0;
58
+ while (size < bytes.length) {
59
+ const result = await handle.read(bytes, size, bytes.length - size, size);
60
+ if (result.bytesRead === 0) break;
61
+ size += result.bytesRead;
62
+ }
63
+ if (size > cap) throw new Error('Memory byte cap exceeded');
64
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size));
65
+ if (text.includes('\0')) throw new Error('Memory must be UTF-8 text');
66
+ return text;
67
+ } finally { await handle.close(); }
68
+ }
69
+ async function list({ kind }, _optional = {}) {
70
+ const base = await root(kind).catch(error => { if (code(error) === 'ENOENT') return undefined; throw error; });
71
+ if (!base) return [];
72
+ const files = [];
73
+ let total = 0, entriesSeen = 0;
74
+ async function walk(prefix) {
75
+ let entries;
76
+ try { entries = await fs.readdir(path.join(base, prefix), { withFileTypes: true }); }
77
+ catch (error) { if (code(error) === 'ENOENT' && prefix === '') return; throw error; }
78
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
79
+ if (++entriesSeen > limits.files * 4) throw new Error('Memory entry cap exceeded');
80
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
81
+ const filename = await contain({ root: base, entryPath: relativePath });
82
+ if (entry.isDirectory()) await walk(relativePath);
83
+ else if (entry.isFile()) {
84
+ if (files.length >= limits.files) throw new Error('Memory file cap exceeded');
85
+ const text = await readText(filename, limits[kind] - total);
86
+ total += Buffer.byteLength(text, 'utf8');
87
+ files.push({ relativePath, text });
88
+ } else throw new Error('Memory symlinks and special files are refused');
89
+ }
90
+ }
91
+ await walk('');
92
+ return files;
93
+ }
94
+ async function read({ kind, entryPath }, _optional = {}) {
95
+ const base = await root(kind);
96
+ return readText(await contain({ root: base, entryPath }), limits[kind]);
97
+ }
98
+ async function write({ kind, entryPath, text }, _optional = {}) {
99
+ checkKind(kind);
100
+ if (typeof text !== 'string' || text.includes('\0') || Buffer.from(text, 'utf8').toString('utf8') !== text) throw new Error('Memory must be valid UTF-8 text');
101
+ if (Buffer.byteLength(text, 'utf8') > limits[kind]) throw new Error('Memory byte cap exceeded');
102
+ // The same lock protects total-size accounting for all processes using this plugin.
103
+ return withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run: async () => {
104
+ const base = await root(kind, true);
105
+ const destination = await contain({ root: base, entryPath });
106
+ const normalized = path.relative(base, destination).split(path.sep).join('/');
107
+ const files = await list({ kind });
108
+ const existing = files.find(file => file.relativePath === normalized);
109
+ const total = files.reduce((sum, file) => sum + Buffer.byteLength(file.text, 'utf8'), 0);
110
+ if ((!existing && files.length >= limits.files) || total - Buffer.byteLength(existing?.text ?? '', 'utf8') + Buffer.byteLength(text, 'utf8') > limits[kind]) throw new Error('Memory cap exceeded');
111
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
112
+ await contain({ root: base, entryPath });
113
+ const temporary = path.join(path.dirname(destination), `.memory-${randomUUID()}.tmp`);
114
+ const handle = await fs.open(temporary, 'wx', 0o600);
115
+ try {
116
+ try { await handle.writeFile(text, 'utf8'); await handle.sync(); } finally { await handle.close(); }
117
+ await fs.rename(temporary, destination);
118
+ } finally { await fs.rm(temporary, { force: true }); }
119
+ return { relativePath: normalized, bytes: Buffer.byteLength(text, 'utf8') };
120
+ } });
121
+ }
122
+ // The plugin-facing capability has no notes writer, even if an input tries to supply kind/id.
123
+ return { list, read, writeNote: ({ entryPath, text }, optional = {}) => write({ kind: 'notes', entryPath, text }, optional),
124
+ learned: { read: ({ entryPath }, optional = {}) => read({ kind: 'learned', entryPath }, optional),
125
+ write: ({ entryPath, text }, optional = {}) => write({ kind: 'learned', entryPath, text }, optional) } };
126
+ }
127
+
128
+ /** Interrupted moves are resumable; refused legacy entries are kept in quarantine and reported. */
129
+ export async function migratePluginLayout({ filesystem: fs, contain, workspaceRoot, parsePluginId, withLock, onEvent }, _optional = {}) {
130
+ await fs.mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
131
+ // Containment returns canonical paths; relative moves must use the same root (e.g. /var -> /private/var).
132
+ workspaceRoot = await fs.realpath(workspaceRoot);
133
+ const marker = path.join(workspaceRoot, '.agent-plugin-layout-migrated');
134
+ const emit = (event, message) => onEvent?.({ event, message });
135
+ return withLock({ lockPath: `${marker}.lock`, run: async () => {
136
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.basename(marker) });
137
+ try { if (JSON.parse(await fs.readFile(marker, 'utf8')).layoutVersion === 2) return { complete: true, moved: 0 }; }
138
+ catch (error) { if (code(error) !== 'ENOENT') emit('migration-marker-invalid', String(error)); }
139
+ let complete = true, moved = 0;
140
+ let quarantineRoot;
141
+ async function entries(directory) {
142
+ try { return await fs.readdir(directory, { withFileTypes: true }); }
143
+ catch (error) { if (code(error) === 'ENOENT') return []; throw error; }
144
+ }
145
+ async function renameEntry({ source, destination, frozen }, _optional = {}) {
146
+ await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
147
+ const mode = frozen ? (await fs.stat(source)).mode & 0o777 : undefined;
148
+ if (frozen) await fs.chmod(source, mode | 0o700);
149
+ try { await fs.rename(source, destination); }
150
+ catch (error) { if (frozen) await fs.chmod(source, mode).catch(() => undefined); throw error; }
151
+ if (frozen) await fs.chmod(destination, mode);
152
+ }
153
+ async function quarantine({ source, reason, frozen = false }, _optional = {}) {
154
+ // Validate ancestors, then rename the entry itself: even an unsafe symlink is preserved,
155
+ // never followed into another plugin or outside the workspace.
156
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, path.dirname(source)) });
157
+ if (!quarantineRoot) {
158
+ const base = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: 'staging/legacy-quarantine' });
159
+ await fs.mkdir(base, { recursive: true, mode: 0o700 });
160
+ const candidate = path.join(base, `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}`);
161
+ // Exclusive creation prevents a retry or another boot from overwriting saved bytes.
162
+ await fs.mkdir(candidate, { mode: 0o700 });
163
+ quarantineRoot = candidate;
164
+ }
165
+ const destinationParent = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot,
166
+ entryPath: path.relative(workspaceRoot, path.join(quarantineRoot, path.relative(workspaceRoot, path.dirname(source)))) });
167
+ // Keep the native directory-entry name verbatim, even if it is not a valid portable package
168
+ // path (e.g. a literal backslash on POSIX). The fresh quarantine tree has no existing leaf.
169
+ const destination = path.join(destinationParent, path.basename(source));
170
+ await renameEntry({ source, destination, frozen });
171
+ emit('migration-quarantined', `${source} -> ${destination}: ${reason}`);
172
+ }
173
+ async function move(source, destination, frozen = false) {
174
+ await contain({ root: workspaceRoot, entryPath: path.relative(workspaceRoot, source) });
175
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, destination) });
176
+ let exists = false;
177
+ try { await fs.stat(destination); exists = true; }
178
+ catch (error) { if (code(error) !== 'ENOENT') throw error; }
179
+ if (exists) {
180
+ // The Layout B copy wins. This runs under the plugin lock, so no in-flight move is discarded.
181
+ await quarantine({ source, frozen, reason: 'Migration destination already exists; Layout B copy kept' });
182
+ return;
183
+ }
184
+ await renameEntry({ source, destination, frozen });
185
+ moved++;
186
+ emit('migration-moved', `${source} -> ${destination}`);
187
+ }
188
+ const oldPackages = path.join(workspaceRoot, 'packages', 'sha256');
189
+ for (const entry of await entries(oldPackages)) {
190
+ const source = path.join(oldPackages, entry.name);
191
+ try {
192
+ if (!DIGEST.test(entry.name) || !entry.isDirectory()) {
193
+ await quarantine({ source, frozen: entry.isDirectory(), reason: 'Unrecognized legacy package entry' });
194
+ continue;
195
+ }
196
+ let pluginId, paths;
197
+ try {
198
+ await contain({ root: workspaceRoot, entryPath: `packages/sha256/${entry.name}` });
199
+ const manifestPath = await contain({ root: source, entryPath: 'plugin.json' });
200
+ pluginId = parsePluginId({ value: JSON.parse(await fs.readFile(manifestPath, 'utf8')) });
201
+ paths = pluginStatePaths({ workspaceRoot, pluginId });
202
+ } catch (error) {
203
+ // Missing/invalid manifests are refused entries; filesystem failures remain resumable.
204
+ if (code(error) && !['ENOENT', 'EISDIR'].includes(code(error))) throw error;
205
+ await quarantine({ source, frozen: true, reason: String(error) });
206
+ continue;
207
+ }
208
+ await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId, withLock, run: () => move(source, path.join(paths.packages, entry.name), true) });
209
+ } catch (error) { complete = false; emit('migration-failed', `${entry.name}: ${String(error)}`); }
210
+ }
211
+ // Layout A memory and PLUGIN_DATA may exist without an installed package; migrate them too.
212
+ for (const bucket of ['memory', 'data']) {
213
+ for (const entry of await entries(path.join(workspaceRoot, bucket))) {
214
+ const source = path.join(workspaceRoot, bucket, entry.name);
215
+ try {
216
+ if (!entry.isDirectory()) {
217
+ await quarantine({ source, reason: 'Legacy state is not a directory' });
218
+ continue;
219
+ }
220
+ let paths;
221
+ try { paths = pluginStatePaths({ workspaceRoot, pluginId: entry.name }); }
222
+ catch (error) { await quarantine({ source, reason: String(error) }); continue; }
223
+ await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId: entry.name, withLock, run: () => move(source, bucket === 'data' ? paths.data : path.join(paths.root, 'memory')) });
224
+ } catch (error) { complete = false; emit('migration-failed', `${bucket}/${entry.name}: ${String(error)}`); }
225
+ }
226
+ }
227
+ for (const folder of await entries(workspaceRoot)) {
228
+ if (!folder.isDirectory() || !SAFE_ID.test(folder.name) || ['staging', 'packages', 'memory', 'data'].includes(folder.name)) continue;
229
+ try {
230
+ await withPluginStateLock({ filesystem: fs, contain, workspaceRoot, pluginId: folder.name, withLock, run: async () => {
231
+ const paths = pluginStatePaths({ workspaceRoot, pluginId: folder.name });
232
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, paths.packages) });
233
+ const digests = (await entries(paths.packages)).filter(digest => digest.isDirectory() && DIGEST.test(digest.name));
234
+ for (const digest of digests) {
235
+ const packageRoot = await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, path.join(paths.packages, digest.name)) });
236
+ // Legacy install froze every package; a crash after rename can leave only its root writable.
237
+ await fs.chmod(packageRoot, 0o555);
238
+ }
239
+ // Wait until legacy memory/data moves have finished before creating destinations for them.
240
+ if (complete && digests.length) for (const directory of [paths.learned, paths.notes, paths.data]) {
241
+ await assertPluginStatePath({ filesystem: fs, contain, workspaceRoot, entryPath: path.relative(workspaceRoot, directory) });
242
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
243
+ }
244
+ } });
245
+ } catch (error) { complete = false; emit('migration-failed', `${folder.name}: ${String(error)}`); }
246
+ }
247
+ if (complete) {
248
+ for (const directory of [oldPackages, path.dirname(oldPackages), path.join(workspaceRoot, 'memory'), path.join(workspaceRoot, 'data')]) {
249
+ try { await fs.rmdir(directory); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(code(error))) throw error; }
250
+ }
251
+ const temporary = `${marker}.${randomUUID()}.tmp`;
252
+ const handle = await fs.open(temporary, 'wx', 0o600);
253
+ try { await handle.writeFile(JSON.stringify({ layoutVersion: 2 }), 'utf8'); await handle.sync(); }
254
+ finally { await handle.close(); }
255
+ try { await fs.rename(temporary, marker); } finally { await fs.rm(temporary, { force: true }); }
256
+ emit('migration-complete', workspaceRoot);
257
+ }
258
+ return { complete, moved };
259
+ } });
260
+ }