total-recall-brain 3.40.7 → 3.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/bin/total-recall-memory.mjs +0 -0
  2. package/bin/total-recall.mjs +9 -40
  3. package/docs/reference/cli-reference.md +65 -1
  4. package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
  5. package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
  8. package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
  9. package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
  10. package/frontend/src/components/plugins/card/card.css +2 -5
  11. package/frontend/src/components/plugins/card/contract.ts +13 -19
  12. package/frontend/src/components/plugins/card/format.ts +8 -1
  13. package/frontend/src/components/plugins/card/widgets.tsx +4 -35
  14. package/frontend/src/components/plugins/plugin-frame.ts +30 -3
  15. package/frontend/src/pages/PluginsPage.tsx +3 -2
  16. package/package.json +1 -1
  17. package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
  18. package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
  20. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
  21. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
  22. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
  23. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
  24. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
  25. package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
  26. package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
  27. package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
  28. package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
  29. package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
  30. package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
  31. package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
  32. package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
  33. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
  34. package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
  35. package/scaffold/.agent/skills/plugin/references/card.md +90 -0
  36. package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
  37. package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
  38. package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
  39. package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
  40. package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
  41. package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
  43. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
  44. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
  45. package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
  47. package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
  49. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
  53. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
  54. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
  55. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
  56. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
  58. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
  59. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
  60. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
  61. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
  62. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
  63. package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
  64. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
  67. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
  68. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
  69. package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
  70. package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
  71. package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
  72. package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
  73. package/src/cli/brain.mjs +4 -2
  74. package/src/cli/context-stats.mjs +104 -0
  75. package/src/cli/context.mjs +8 -0
  76. package/src/cli/daemon.mjs +10 -1
  77. package/src/cli/export.mjs +3 -2
  78. package/src/cli/ingest-okf.mjs +12 -15
  79. package/src/cli/init.mjs +0 -85
  80. package/src/cli/lint.mjs +3 -2
  81. package/src/cli/plugin/copies.mjs +68 -0
  82. package/src/cli/plugin/create.mjs +22 -24
  83. package/src/cli/plugin/index.mjs +49 -3
  84. package/src/cli/plugin/search.mjs +19 -2
  85. package/src/cli/rebuild.mjs +2 -0
  86. package/src/cli/remember.mjs +23 -0
  87. package/src/cli/status.mjs +10 -3
  88. package/src/cli/update.mjs +3 -2
  89. package/src/core/action-predictor.mjs +161 -0
  90. package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
  91. package/src/core/app-deploy/source.mjs +16 -9
  92. package/src/core/automatic-brain-updates.mjs +6 -3
  93. package/src/core/brain-registry.mjs +19 -2
  94. package/src/core/brain-state.mjs +23 -0
  95. package/src/core/cli-commands.mjs +1 -0
  96. package/src/core/cli-maintenance.mjs +1 -1
  97. package/src/core/cli-semantic-index.mjs +1 -1
  98. package/src/core/command-surface.mjs +3 -3
  99. package/src/core/composed-command-resolution.mjs +21 -0
  100. package/src/core/config.mjs +84 -0
  101. package/src/core/confined-memory.mjs +93 -0
  102. package/src/core/context-telemetry.mjs +214 -0
  103. package/src/core/daemon-control.mjs +14 -43
  104. package/src/core/fast-recall.mjs +1 -1
  105. package/src/core/memory-brain.mjs +2 -0
  106. package/src/core/plugin-activation.mjs +38 -6
  107. package/src/core/plugin-bundle.mjs +54 -0
  108. package/src/core/plugin-catalog.mjs +1 -1
  109. package/src/core/plugin-context.mjs +1 -1
  110. package/src/core/plugin-contracts/card.mjs +69 -37
  111. package/src/core/plugin-loader.mjs +5 -6
  112. package/src/core/plugin-runner.mjs +13 -6
  113. package/src/core/plugin-runtime.mjs +5 -4
  114. package/src/core/plugin-setup.mjs +5 -3
  115. package/src/core/plugin-state-store.mjs +12 -1
  116. package/src/core/plugin-state.mjs +46 -5
  117. package/src/core/plugin-store.mjs +78 -3
  118. package/src/core/plugin-tasks.mjs +3 -1
  119. package/src/core/plugin-updates.mjs +117 -0
  120. package/src/core/plugin-vendor.mjs +10 -4
  121. package/src/core/project-brain.mjs +14 -41
  122. package/src/core/project-id.mjs +4 -1
  123. package/src/core/required-plugins.mjs +151 -13
  124. package/src/core/skills-registry.mjs +27 -3
  125. package/src/core/ssss-kernel-bridge.mjs +3 -1
  126. package/src/core/startup-health.mjs +66 -16
  127. package/src/core/surface.mjs +51 -44
  128. package/src/core/testing/plugin-fixture.mjs +69 -0
  129. package/src/core/vault-files.mjs +13 -0
  130. package/src/core/vault.mjs +4 -30
  131. package/src/server/index.mjs +13 -1
  132. package/src/server/invoke-decision-child.mjs +49 -0
  133. package/src/server/invoke-execution.mjs +87 -0
  134. package/src/server/invoke-policy.mjs +64 -0
  135. package/src/server/invoke-read-child.mjs +26 -0
  136. package/src/server/invoke-targets.mjs +49 -0
  137. package/src/server/invoke-write-child.mjs +19 -0
  138. package/src/server/routes/invoke.mjs +314 -0
  139. package/src/server/routes/plugins.mjs +1 -1
  140. package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
  141. package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
  142. package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
  143. package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
  144. package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
  145. package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
  146. package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
  147. package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
  148. package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
  149. package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
  150. package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
  151. package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
  152. package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
  153. package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
  154. package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
  155. package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
  156. package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
  157. package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
  158. package/scaffold/openwiki/README.md +0 -10
  159. package/scaffold/openwiki/architecture.md +0 -5
  160. package/scaffold/openwiki/ide.md +0 -10
  161. package/scaffold/openwiki/memory.md +0 -11
  162. package/scaffold/openwiki/secrets.md +0 -7
  163. package/scaffold/openwiki/skills.md +0 -7
  164. package/src/core/okf-adapter.mjs +0 -559
  165. package/templates/openwiki/README.md +0 -10
  166. package/templates/openwiki/architecture.md +0 -5
  167. package/templates/openwiki/ide.md +0 -10
  168. package/templates/openwiki/memory.md +0 -11
  169. package/templates/openwiki/secrets.md +0 -7
  170. package/templates/openwiki/skills.md +0 -7
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { logger } from './logger.mjs';
7
7
  import { agentDir, brainDir } from './config.mjs';
8
- import { readProcessCommand } from './pid-lock.mjs';
8
+ import { observeDaemon } from './startup-health.mjs';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const ROOT = path.join(__dirname, '..', '..');
@@ -22,46 +22,19 @@ const LOG_FILE = path.join(brainDir, 'logs', 'daemon.log');
22
22
  * @returns {number|null} The active PID, or null if not running/invalid.
23
23
  */
24
24
  export function readPid() {
25
- try {
26
- if (!fs.existsSync(PID_FILE)) return null;
27
- const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
28
- if (isNaN(pid) || pid <= 0) return null;
29
-
30
- // Check if the process is alive using signal 0
31
- try {
32
- process.kill(pid, 0);
33
- const command=readProcessCommand(pid);
34
- if(!command)throw new Error('Cannot verify daemon process identity');
35
- if(!command.includes(path.join('core','daemon-loop.mjs')) && !command.includes(path.join('core','dream.mjs')))return null;
36
- return pid;
37
- } catch {
38
- return null;
39
- }
40
- } catch {
41
- return null;
42
- }
25
+ const observation = getDaemonObservation();
26
+ return observation.status === 'running' ? observation.pid : null;
43
27
  }
44
28
 
29
+ export function getDaemonObservation() { return observeDaemon(brainDir); }
30
+
45
31
  /**
46
32
  * Returns the status of the daemon process.
47
- * @returns {'running'|'dead'|'not_started'}
33
+ * @returns {'running'|'dead'|'not_started'|'conflict'|'unknown'}
48
34
  */
49
35
  export function getDaemonStatus() {
50
- try {
51
- const pid = readPid();
52
- if (pid) {
53
- return 'running';
54
- }
55
-
56
- // If process is not running but PID file exists, it's considered dead
57
- if (fs.existsSync(PID_FILE)) {
58
- return 'dead';
59
- }
60
-
61
- return 'not_started';
62
- } catch {
63
- return 'not_started';
64
- }
36
+ const { status } = getDaemonObservation();
37
+ return status === 'not-started' ? 'not_started' : status === 'stopped' ? 'dead' : status;
65
38
  }
66
39
 
67
40
  /**
@@ -69,7 +42,11 @@ export function getDaemonStatus() {
69
42
  * @returns {number} The PID of the newly spawned daemon.
70
43
  */
71
44
  export function startDaemon() {
72
- const existingPid = readPid();
45
+ const observation = getDaemonObservation();
46
+ if (['conflict', 'unknown'].includes(observation.status)) {
47
+ throw new Error(`Daemon start refused: ${observation.reason || observation.status}; expected ${observation.expected_entry}${observation.entry ? `, observed ${observation.entry}` : ''}`);
48
+ }
49
+ const existingPid = observation.status === 'running' ? observation.pid : null;
73
50
  if (existingPid) {
74
51
  logger.warn('daemon-control', `Daemon already running under PID ${existingPid}.`);
75
52
  return existingPid;
@@ -77,13 +54,7 @@ export function startDaemon() {
77
54
 
78
55
  const dreamScript = path.join(ROOT, 'src', 'core', 'daemon-loop.mjs');
79
56
  if (!fs.existsSync(dreamScript)) {
80
- // Fallback to dream.mjs for backward compatibility
81
- const fallback = path.join(ROOT, 'src', 'core', 'dream.mjs');
82
- if (fs.existsSync(fallback)) {
83
- logger.warn('daemon-control', 'daemon-loop.mjs not found, falling back to dream.mjs');
84
- } else {
85
- throw new Error(`Daemon script not found at ${dreamScript}`);
86
- }
57
+ throw new Error(`Daemon script not found at ${dreamScript}`);
87
58
  }
88
59
 
89
60
  const logsDir = path.dirname(LOG_FILE);
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import matter from './frontmatter.mjs';
5
- import { atomicWrite, isSafeVaultName } from './vault.mjs';
5
+ import { atomicWrite, isSafeVaultName } from './vault-files.mjs';
6
6
 
7
7
  const caches = new Map();
8
8
  const words = text => [...new Set(String(text).toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [])];
@@ -2,6 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { writeFileSecure } from './secure-file.mjs';
5
+ import { ensureProjectId } from './project-id.mjs';
5
6
 
6
7
  function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
7
8
 
@@ -58,6 +59,7 @@ export function registerProjectBrain(globalBrainDir, entry) {
58
59
  name: entry.name,
59
60
  path: abs,
60
61
  brainDir: entry.brainDir,
62
+ project_id: ensureProjectId(entry.brainDir).project_id,
61
63
  full_brain: true,
62
64
  layer: 'project',
63
65
  tags: entry.tags || ['project-brain'],
@@ -1,15 +1,47 @@
1
1
  /** Host activation policy; never pretends a provider or external daemon is healthy. */
2
2
  import { getPlugin } from './plugin-loader.mjs';
3
- import { patchPluginRecord, emit, vaultFor, recompileAfterPluginMutation } from './plugin-store.mjs';
3
+ import { patchPluginRecord, patchProjectActivationRecord, emit, vaultFor, recompileAfterPluginMutation } from './plugin-store.mjs';
4
4
  import { pluginActivation } from './plugin-state.mjs';
5
5
  export { pluginActivation };
6
6
 
7
- export async function setPluginActive(id, enabled, { projectRoot = process.cwd() } = {}) {
7
+ /**
8
+ * Switch one plugin on or off.
9
+ *
10
+ * A plugin installed in one repository alone (a project copy) carries its own
11
+ * setting. A plugin in the shared folder is switched for the whole machine
12
+ * (`scope: 'shared'`), for the current repository alone (the default), or back to
13
+ * following the shared setting (`clear: true`).
14
+ */
15
+ export async function setPluginActive(id, enabled, { projectRoot = process.cwd(), scope = 'project', clear = false } = {}) {
16
+ if (clear === true) {
17
+ const plugin = getPlugin(id, projectRoot);
18
+ if (!plugin?.valid) throw new Error(`Plugin ${id} is missing or invalid`);
19
+ await patchProjectActivationRecord(id, projectRoot, null);
20
+ await emit(vaultFor(plugin), id, 'activation-cleared', { projectRoot });
21
+ const compilation = await recompileAfterPluginMutation(projectRoot, false);
22
+ return { pluginId: id, activation: pluginActivation(plugin, { projectRoot }), scope: 'shared', compilation };
23
+ }
8
24
  if (typeof enabled !== 'boolean') throw new Error('Plugin activation requires a boolean');
9
25
  const plugin = getPlugin(id, projectRoot);
10
26
  if (!plugin?.valid) throw new Error(`Plugin ${id} is missing or invalid`);
11
- await patchPluginRecord(plugin, { runtime_enabled: enabled });
12
- await emit(vaultFor(plugin), id, enabled ? 'activated' : 'deactivated', { enabled });
13
- const compilation = await recompileAfterPluginMutation(projectRoot, plugin.scope === 'global' || plugin.scope === 'scaffold');
14
- return { pluginId: id, activation: pluginActivation(plugin), compilation };
27
+ if (scope === 'shared') {
28
+ if (plugin.scope !== 'global') throw new Error(`Plugin ${id} is a ${plugin.scope} copy without a shared setting to change`);
29
+ await patchPluginRecord(plugin, { runtime_enabled: enabled });
30
+ await emit(vaultFor(plugin), id, enabled ? 'activated' : 'deactivated', { enabled, scope: 'shared' });
31
+ const compilation = await recompileAfterPluginMutation(projectRoot, true);
32
+ return { pluginId: id, activation: pluginActivation(plugin, { projectRoot }), scope: 'shared', compilation };
33
+ }
34
+ // Repository scope. A repository-owned copy already is that repository's setting.
35
+ if (plugin.scope !== 'global') {
36
+ await patchPluginRecord(plugin, { runtime_enabled: enabled });
37
+ await emit(vaultFor(plugin), id, enabled ? 'activated' : 'deactivated', { enabled, scope: plugin.scope });
38
+ // A project copy compiles the project's own surface and catalog. Only the
39
+ // shared (scaffold) layer also rebuilds every registered project.
40
+ const compilation = await recompileAfterPluginMutation(projectRoot, plugin.scope === 'scaffold');
41
+ return { pluginId: id, activation: pluginActivation(plugin, { projectRoot }), scope: plugin.scope, compilation };
42
+ }
43
+ await patchProjectActivationRecord(id, projectRoot, enabled, { manifest: plugin.manifest });
44
+ await emit(vaultFor(plugin), id, enabled ? 'activated' : 'deactivated', { enabled, projectRoot, scope: 'project' });
45
+ const compilation = await recompileAfterPluginMutation(projectRoot, false);
46
+ return { pluginId: id, activation: pluginActivation(plugin, { projectRoot }), scope: 'project', compilation };
15
47
  }
@@ -49,6 +49,60 @@ export function listPluginFiles(pluginDir) {
49
49
  return out;
50
50
  }
51
51
 
52
+ const BRICK_ROOT_FILES = /^LICENSE[^/]*$/i;
53
+
54
+ /**
55
+ * Files a plugin distributes to one audience.
56
+ *
57
+ * `host` is every file, for a mesh peer or a Total Recall installation.
58
+ * `brick` is the host-free capability only: the directory holding
59
+ * `capability.entry`, plus LICENSE. package.json is regenerated with only its
60
+ * runtime fields, and the README is left behind because it documents the host. The UI, the CLI
61
+ * handler and the host manifest never leave with a brick, so a vendored copy
62
+ * has no UI and no path back to Total Recall. Its plugin.json and package.json
63
+ * are reduced copies, returned as `data` rather than read from disk.
64
+ * @returns {Array<{ path: string, abs: string, size: number, mode: number, data?: Buffer }>}
65
+ */
66
+ export function pluginBundleFiles(pluginDir, { audience = 'host' } = {}) {
67
+ const files = listPluginFiles(pluginDir);
68
+ if (audience === 'host') return files;
69
+ if (audience !== 'brick') throw new Error(`Unknown bundle audience '${audience}'`);
70
+ const root = fs.realpathSync(pluginDir);
71
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8'));
72
+ const entry = manifest.capability?.entry;
73
+ if (typeof entry !== 'string') throw new Error(`Plugin '${manifest.id}' declares no capability.entry, so it cannot be vendored as a brick`);
74
+ const capDir = path.posix.dirname(entry);
75
+ const inCap = (p) => (capDir === '.' ? p === entry : p.startsWith(`${capDir}/`));
76
+ const kept = files.filter((f) => f.path.split('/')[0] !== 'ui' && (inCap(f.path) || BRICK_ROOT_FILES.test(f.path)));
77
+ if (!kept.some((f) => f.path === entry)) throw new Error(`capability.entry '${entry}' does not exist in plugin '${manifest.id}'`);
78
+ const reduced = Buffer.from(`${JSON.stringify({
79
+ id: manifest.id, name: manifest.name, version: manifest.version, description: manifest.description,
80
+ brick: true, capability: manifest.capability,
81
+ ...(manifest.requires ? { requires: manifest.requires } : {})
82
+ }, null, 2)}\n`);
83
+ const generated = [{ path: 'plugin.json', abs: null, size: reduced.length, mode: 0o644, data: reduced }];
84
+ const pkgFile = path.join(root, 'package.json');
85
+ if (fs.existsSync(pkgFile)) {
86
+ const pkg = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
87
+ const runtime = {};
88
+ for (const k of ['name', 'version', 'type', 'description', 'license', 'dependencies']) if (pkg[k] !== undefined) runtime[k] = pkg[k];
89
+ // A capability that declares its own dependencies keeps only those: the plugin's other
90
+ // dependencies serve the host side (routes, CLI) and never leave with the brick.
91
+ const own = manifest.capability.dependencies;
92
+ if (own !== undefined) {
93
+ if (!Array.isArray(own) || own.some((d) => typeof d !== 'string')) throw new Error(`Plugin '${manifest.id}' capability.dependencies must be an array of package names`);
94
+ const missing = own.filter((d) => !pkg.dependencies?.[d]);
95
+ if (missing.length) throw new Error(`Plugin '${manifest.id}' capability.dependencies names packages its package.json does not depend on: ${missing.join(', ')}`);
96
+ const kept = Object.fromEntries(own.map((d) => [d, pkg.dependencies[d]]));
97
+ if (Object.keys(kept).length) runtime.dependencies = kept; else delete runtime.dependencies;
98
+ }
99
+ const data = Buffer.from(`${JSON.stringify(runtime, null, 2)}\n`);
100
+ generated.push({ path: 'package.json', abs: null, size: data.length, mode: 0o644, data });
101
+ }
102
+ return [...kept, ...generated]
103
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
104
+ }
105
+
52
106
  function hashEntries(entries) {
53
107
  const h = crypto.createHash('sha256');
54
108
  for (const e of entries) {
@@ -15,7 +15,7 @@ export function buildPluginCatalog(projectRoot = process.cwd(), { discover = dis
15
15
  return {
16
16
  id: plugin.id, name: manifest.name || plugin.id, version: manifest.version || null,
17
17
  scope: plugin.scope, valid: plugin.valid === true, errors: plugin.errors || [],
18
- activation: pluginActivation(plugin),
18
+ activation: pluginActivation(plugin, { projectRoot }),
19
19
  description: manifest.description || '', namespace: manifest.cli?.command || null,
20
20
  callable, operations: [...(manifest.cli?.subcommands || []).filter(command => !['on', 'off'].includes(command.name)),
21
21
  ...['on', 'off'].map(name => ({ name, description: `${name === 'on' ? 'Enable' : 'Disable'} host execution and compile surfaces`, effect: 'write', output: 'json', args: [] }))].map(command => ({
@@ -18,7 +18,7 @@ export async function assemblePluginContexts({ projectRoot = process.cwd(), vaul
18
18
  const blocks = [];
19
19
 
20
20
  for (const plugin of plugins) {
21
- if (!plugin.valid || !pluginActivation(plugin).enabled) continue;
21
+ if (!plugin.valid || !pluginActivation(plugin, { projectRoot: resolvedProjectRoot }).enabled) continue;
22
22
  const { manifest, dir, id } = plugin;
23
23
  const name = manifest.name || id;
24
24
 
@@ -1,9 +1,14 @@
1
1
  /**
2
- * Functional card contract. The host renders one card per plugin from these
3
- * declarations: what the plugin can do (commands with typed arguments and an
4
- * effect), what it needs (settings, credentials), and how to read its real
5
- * state (a status command). Everything here is optional; a plugin without it
6
- * still gets a card listing its commands and secrets.
2
+ * Functional card contract. A plugin's card face is its own bespoke Web
3
+ * Component, declared as a `ui.elements[]` entry of kind `card`. There is no
4
+ * template: no shared layout, no widget vocabulary, no allowlist of shapes.
5
+ * Core validates that the face exists and that the commands behind it are
6
+ * declared; it never dictates how the face is arranged.
7
+ *
8
+ * A plugin carries exactly one face and AS MANY secondary screens as it needs,
9
+ * each declared `kind: 'panel'`. Screens are the plugin's own design: it names
10
+ * them, orders them and decides what is on them. The host imposes no count and
11
+ * no screen set, and never derives a screen from the command list.
7
12
  */
8
13
  const plain = (v) => v && typeof v === 'object' && !Array.isArray(v);
9
14
  const COMMAND = /^[a-z][a-z0-9-]{0,63}$/;
@@ -15,14 +20,10 @@ export const OUTPUT_KINDS = ['json', 'text'];
15
20
  export const TIERS = ['core', 'official', 'personal', 'community'];
16
21
  export const LICENSE_CLASSES = ['open', 'closed'];
17
22
  const STATUS_STATES = ['ok', 'warn', 'error', 'unknown'];
18
- export const CARD_TEMPLATES = ['console', 'ledger', 'monitor', 'inbox', 'dashboard', 'workbench', 'vault'];
19
- export const CARD_WIDGETS = ['status', 'actions', 'table', 'detail', 'log', 'form', 'keypad', 'gauge', 'list', 'settings', 'credentials', 'activity', 'live'];
20
- export const WIDGETS_NEEDING_SOURCE = ['table', 'detail', 'list', 'gauge', 'log'];
21
23
  export const CARD_FONTS = ['system', 'inter', 'ibm-plex', 'space-grotesk', 'jetbrains-mono', 'dm-sans', 'outfit', 'fraunces', 'newsreader', 'chivo-mono'];
22
24
  export const CARD_DENSITIES = ['compact', 'comfortable', 'spacious'];
23
25
  export const CARD_RADII = ['none', 'sm', 'md', 'lg', 'pill'];
24
26
  export const CARD_HEADERS = ['plain', 'ledger-rule', 'band', 'tab-strip', 'badge-left'];
25
- export const CELL_FORMATS = ['text', 'number', 'money', 'date', 'datetime', 'bytes', 'percent', 'status'];
26
27
  export const WORKSPACE_PERMISSIONS = ['microphone', 'camera', 'notifications'];
27
28
  const HEX = /^#[0-9a-fA-F]{6}$/;
28
29
 
@@ -87,7 +88,7 @@ export function validateCardContract(manifest) {
87
88
  if (!LICENSE_CLASSES.includes(manifest.distribution.license)) errors.push(`'distribution.license' must be one of: ${LICENSE_CLASSES.join(', ')}`);
88
89
  }
89
90
  }
90
- validateLayout(manifest, names, errors);
91
+ validateCardFace(manifest, errors, warnings);
91
92
  validateBranding(manifest.branding, errors, warnings);
92
93
  validateWorkspace(manifest.workspace, errors);
93
94
  if (Array.isArray(manifest.secrets)) {
@@ -101,36 +102,67 @@ export function validateCardContract(manifest) {
101
102
  return { errors, warnings, states: STATUS_STATES };
102
103
  }
103
104
 
104
- function validateLayout(manifest, commandNames, errors) {
105
- const layout = manifest.card_layout;
106
- if (layout === undefined) return;
107
- if (!plain(layout)) { errors.push("'card_layout' must be an object"); return; }
108
- if (!CARD_TEMPLATES.includes(layout.template)) errors.push(`'card_layout.template' must be one of: ${CARD_TEMPLATES.join(', ')}`);
109
- if (!Array.isArray(layout.regions) || layout.regions.length === 0 || layout.regions.length > 12) {
110
- errors.push("'card_layout.regions' must be an array of 1 to 12 regions");
111
- return;
105
+ /**
106
+ * Every plugin has a card face: bespoke owner code, declared once in
107
+ * ui.elements[]. A plugin without one is incomplete, and a plugin that declares
108
+ * a shared template is invalid.
109
+ *
110
+ * The *scaffold* emitted by `plugin create` deliberately contains no card: a
111
+ * face is authored by hand once the plugin's function is understood. A scaffold
112
+ * declares `scaffold: true`, so it installs while the author works and reports
113
+ * its missing face as a warning rather than an error. Any other plugin without
114
+ * a face is an error, because nothing else explains why it is missing.
115
+ */
116
+ function validateCardFace(manifest, errors, warnings) {
117
+ if (manifest.card_layout !== undefined) {
118
+ errors.push("'card_layout' is not supported: a card face is bespoke owner code in ui.elements[], never a shared template");
112
119
  }
113
- layout.regions.forEach((region, i) => {
114
- const where = `card_layout.regions[${i}]`;
115
- if (!plain(region)) { errors.push(`${where} must be an object`); return; }
116
- if (!CARD_WIDGETS.includes(region.widget)) { errors.push(`${where}.widget must be one of: ${CARD_WIDGETS.join(', ')}`); return; }
117
- if (WIDGETS_NEEDING_SOURCE.includes(region.widget)) {
118
- const cmd = region.source?.command;
119
- if (typeof cmd !== 'string' || !commandNames.has(cmd)) errors.push(`${where}.source.command must name a declared cli subcommand`);
120
- }
121
- if (region.widget === 'form' && !commandNames.has(region.command)) errors.push(`${where}.command must name a declared cli subcommand`);
122
- if (region.commands !== undefined) {
123
- if (!Array.isArray(region.commands) || region.commands.some((c) => !commandNames.has(c))) errors.push(`${where}.commands must list declared cli subcommands`);
120
+ const elements = manifest.ui?.elements;
121
+ // A brick is distributed as capability code alone. It carries no UI, so it
122
+ // has no face; every other plugin still requires exactly one.
123
+ if (manifest.brick === true) {
124
+ validateCapability(manifest, errors);
125
+ if (manifest.ui !== undefined && Array.isArray(elements) && elements.length > 0) {
126
+ errors.push("a brick ships without UI: remove 'ui.elements' or drop 'brick: true'");
124
127
  }
125
- if (region.columns !== undefined) {
126
- if (!Array.isArray(region.columns) || region.columns.length === 0) errors.push(`${where}.columns must be a non-empty array`);
127
- else region.columns.forEach((c, j) => {
128
- if (!plain(c) || typeof c.key !== 'string' || typeof c.label !== 'string') errors.push(`${where}.columns[${j}] needs key and label`);
129
- else if (c.format !== undefined && !CELL_FORMATS.includes(c.format)) errors.push(`${where}.columns[${j}].format must be one of: ${CELL_FORMATS.join(', ')}`);
130
- });
128
+ return;
129
+ }
130
+ if (manifest.capability !== undefined) validateCapability(manifest, errors);
131
+ if (!Array.isArray(elements) || elements.length === 0) {
132
+ // A scaffold is a freshly created plugin whose face has not been authored
133
+ // yet. It must be installable so the author can iterate, but it is not a
134
+ // shippable plugin, so the missing face is reported and stays visible.
135
+ if (manifest.scaffold === true) {
136
+ warnings.push("'ui.elements' declares no card face yet: author ui/card.js and ui/DESIGN.md by hand for this plugin's own function, then declare the face in ui.elements[]");
137
+ return;
131
138
  }
132
- if (region.widget === 'table' && !Array.isArray(region.columns)) errors.push(`${where}.columns is required for a table`);
133
- });
139
+ errors.push("'ui.elements' must declare the plugin's card face; every plugin has one, authored by hand");
140
+ return;
141
+ }
142
+ // One face, and as many secondary screens as the plugin needs. Cardinality is
143
+ // checked for `card` alone, so panels may be declared in any number and on
144
+ // either side of the face.
145
+ const faces = elements.filter((element) => plain(element) && element.kind === 'card');
146
+ if (faces.length === 0) errors.push("'ui.elements' must declare an element of kind 'card'");
147
+ if (faces.length > 1) errors.push("'ui.elements' must declare exactly one card face; further screens use kind 'panel'");
148
+ }
149
+
150
+ /**
151
+ * `capability {entry, exports}` names the host-free module a project vendors:
152
+ * a relative path inside the plugin, and the exported names it promises.
153
+ */
154
+ function validateCapability(manifest, errors) {
155
+ const cap = manifest.capability;
156
+ if (!plain(cap)) { errors.push("'capability' must declare {entry, exports}"); return; }
157
+ const entry = cap.entry;
158
+ if (typeof entry !== 'string' || !/^[A-Za-z0-9._\/-]{1,200}\.m?js$/.test(entry) || entry.startsWith('/') || entry.split('/').includes('..')) {
159
+ errors.push("capability.entry must be a relative .js or .mjs path inside the plugin");
160
+ } else if (entry.split('/')[0] === 'ui') {
161
+ errors.push('capability.entry must not live under ui/: a brick contains no UI');
162
+ }
163
+ if (!Array.isArray(cap.exports) || cap.exports.length === 0 || cap.exports.some((n) => typeof n !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(n))) {
164
+ errors.push('capability.exports must be a non-empty array of export names');
165
+ }
134
166
  }
135
167
 
136
168
  function validateBranding(branding, errors, warnings) {
@@ -29,7 +29,7 @@ export function globalPluginsDir() {
29
29
  }
30
30
 
31
31
  export function bundledPluginsDir() {
32
- return process.env._TR_TEST_BUNDLED_PLUGINS_DIR || path.join(PACKAGE_ROOT, 'scaffold', '.agent', 'skills', 'total-recall', 'plugins');
32
+ return path.join(PACKAGE_ROOT, 'scaffold', '.agent', 'skills', 'total-recall', 'plugins');
33
33
  }
34
34
 
35
35
  /** The vault that owns records for plugins installed in `pluginsDir` (`<brain>/plugins` → `<brain>/memory-vault`). */
@@ -434,11 +434,10 @@ export function discoverPlugins(projectRoot = process.cwd()) {
434
434
  export function listBundledPlugins(projectRoot = process.cwd()) {
435
435
  const seen = new Set();
436
436
  const out = [];
437
- // Under test the fixture directory is the only source, so results never
438
- // depend on plugins installed in the developer's own brain.
439
- const dirs = process.env._TR_TEST_BUNDLED_PLUGINS_DIR
440
- ? [bundledPluginsDir()]
441
- : [bundledPluginsDir(), path.join(PACKAGE_ROOT, '.agent', 'skills', 'total-recall', 'plugins')];
437
+ // Each plugin repository owns its own manifest, and nothing keeps a second
438
+ // copy of it. A shared fixture describing a plugin would be a competing
439
+ // definition of that plugin, and it drifts.
440
+ const dirs = [bundledPluginsDir(), path.join(PACKAGE_ROOT, '.agent', 'skills', 'total-recall', 'plugins')];
442
441
  for (const dir of dirs) {
443
442
  if (!fs.existsSync(dir)) continue;
444
443
  for (const p of readPluginsDir(dir, 'bundled')) {
@@ -10,11 +10,16 @@ import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { PACKAGE_ROOT, resolvePluginFile } from './plugin-loader.mjs';
12
12
  import { readPluginRecord } from './plugin-store.mjs';
13
+ import { pluginExecutionEnabled } from './plugin-state.mjs';
13
14
  import { setPluginActive } from './plugin-activation.mjs';
14
15
 
15
16
  const CHILD = path.join(path.dirname(fileURLToPath(import.meta.url)), 'plugin-runner-child.mjs');
16
17
  export const DEFAULT_TIMEOUT_MS = 30_000;
17
18
  export const MAX_OUTPUT_BYTES = 256 * 1024;
19
+ // A terminal call (stdin inherited from the operator) is read by a person or an
20
+ // agent, not buffered by the server, so it gets the ceiling the host's other
21
+ // child processes use instead of the server's bound.
22
+ export const CLI_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
18
23
  export const MAX_INPUT_BYTES = 64 * 1024;
19
24
 
20
25
  /** A credential is one declared string on stdin, never a command-line value. */
@@ -37,6 +42,8 @@ export function validatePluginSecretInput(plugin, subcommand, input) {
37
42
  */
38
43
  export function runPluginCommand(plugin, options = {}) {
39
44
  const { subcommand = '', args = [], cwd = process.cwd(), timeoutMs: requestedTimeoutMs, stdin = 'ignore', stdinText, composable = false, secretsBrainDir } = options;
45
+ const maxOutputBytes = options.maxOutputBytes ?? (stdin === 'inherit' ? CLI_MAX_OUTPUT_BYTES : MAX_OUTPUT_BYTES);
46
+ if (!Number.isInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > CLI_MAX_OUTPUT_BYTES) return Promise.reject(new Error(`Plugin output limit must be 1..${CLI_MAX_OUTPUT_BYTES} bytes`));
40
47
  try { validatePluginSecretInput(plugin, subcommand, stdinText); }
41
48
  catch (err) { return Promise.reject(err); }
42
49
  const operation = subcommand || args[0];
@@ -46,7 +53,7 @@ export function runPluginCommand(plugin, options = {}) {
46
53
  output: JSON.stringify(result), durationMs: 0, timedOut: false,
47
54
  }));
48
55
  }
49
- if (readPluginRecord(plugin)?.runtime_enabled === false && !['status', 'health', 'jobs', '--help', '-h', 'help'].includes(operation)) {
56
+ if (!pluginExecutionEnabled(plugin, { projectRoot: cwd }) && !['status', 'health', 'jobs', '--help', '-h', 'help'].includes(operation)) {
50
57
  return Promise.reject(new Error(`Plugin ${plugin.id} is inactive; run total-recall plugin on ${plugin.id}`));
51
58
  }
52
59
  const declaredTimeout = plugin?.manifest?.cli?.subcommands?.find(item => item.name === operation)?.timeout_ms;
@@ -95,9 +102,9 @@ export function runPluginCommand(plugin, options = {}) {
95
102
  // Even a faulty owner that echoes stdin cannot return credential material.
96
103
  if (stdinText !== undefined) return;
97
104
  if (truncated) return;
98
- if (bytes + buf.length > MAX_OUTPUT_BYTES) {
99
- const partial=buf.subarray(0, MAX_OUTPUT_BYTES - bytes);chunks.push(partial);stream.push(partial);
100
- bytes = MAX_OUTPUT_BYTES;
105
+ if (bytes + buf.length > maxOutputBytes) {
106
+ const partial=buf.subarray(0, maxOutputBytes - bytes);chunks.push(partial);stream.push(partial);
107
+ bytes = maxOutputBytes;
101
108
  truncated = true;
102
109
  return;
103
110
  }
@@ -124,11 +131,11 @@ export function runPluginCommand(plugin, options = {}) {
124
131
  clearTimeout(timer);
125
132
  let output = Buffer.concat(chunks).toString('utf8');
126
133
  if (spawnError) output += `${output ? '\n' : ''}[runner] ${spawnError.message}`;
127
- if (truncated) output += `\n[runner] output truncated at ${MAX_OUTPUT_BYTES} bytes`;
134
+ if (truncated) output += `\n[runner] output truncated at ${maxOutputBytes} bytes`;
128
135
  if (timedOut) output += `\n[runner] killed after ${timeoutMs} ms`;
129
136
  let stderr=Buffer.concat(stderrChunks).toString('utf8');
130
137
  if(spawnError)stderr+=`\n[runner] ${spawnError.message}`;
131
- if(truncated)stderr+=`\n[runner] output truncated at ${MAX_OUTPUT_BYTES} bytes`;
138
+ if(truncated)stderr+=`\n[runner] output truncated at ${maxOutputBytes} bytes`;
132
139
  if(timedOut)stderr+=`\n[runner] killed after ${timeoutMs} ms`;
133
140
  resolve({
134
141
  ok: !spawnError && !timedOut && exitCode === 0,
@@ -6,7 +6,7 @@ import { pluginActivation } from './plugin-state.mjs';
6
6
  export async function loadPluginModule(id, name, { activeRequired = false, projectRoot = process.cwd() } = {}) {
7
7
  const plugin = discoverPlugins(projectRoot).find(item => item.id === id);
8
8
  if (!plugin?.valid) throw new Error(`Required plugin ${id} is missing or invalid`);
9
- if (activeRequired && !pluginActivation(plugin).enabled) throw new Error(`Plugin ${id} is inactive`);
9
+ if (activeRequired && !pluginActivation(plugin, { projectRoot }).enabled) throw new Error(`Plugin ${id} is inactive`);
10
10
  // Pure bootstrap bindings remain importable so an inactive auth/config owner
11
11
  // cannot prevent the management CLI from turning it back on. Execution is
12
12
  // gated at the runner, scheduler and mounted router boundaries.
@@ -21,7 +21,8 @@ export async function loadPluginModule(id, name, { activeRequired = false, proje
21
21
 
22
22
  export async function loadPluginRouters() {
23
23
  const routers = [];
24
- for (const plugin of discoverPlugins(process.cwd()).filter(item => item.valid)) {
24
+ const projectRoot = process.cwd();
25
+ for (const plugin of discoverPlugins(projectRoot).filter(item => item.valid)) {
25
26
  for (const relative of plugin.manifest.runtime?.routers || []) {
26
27
  process.env.TR_PACKAGE_ROOT = PACKAGE_ROOT;
27
28
  const file = resolvePluginFile(plugin.dir, relative);
@@ -29,8 +30,8 @@ export async function loadPluginRouters() {
29
30
  if (typeof module.default !== 'function') throw new Error(`Plugin ${plugin.id} router must export middleware`);
30
31
  const guarded = function router(req, res, next) {
31
32
  const ownsPath = module.default.stack?.some(layer => layer.route && layer.match(req.path));
32
- if (!pluginActivation(plugin).enabled && ownsPath) return res.status(503).json({ error: `Plugin ${plugin.id} is inactive` });
33
- if (!pluginActivation(plugin).enabled) return next();
33
+ if (!pluginActivation(plugin, { projectRoot }).enabled && ownsPath) return res.status(503).json({ error: `Plugin ${plugin.id} is inactive` });
34
+ if (!pluginActivation(plugin, { projectRoot }).enabled) return next();
34
35
  return module.default(req, res, next);
35
36
  };
36
37
  // Preserve the real mounted route stack for API/CLI discovery.
@@ -1,13 +1,15 @@
1
1
  /** Run declared, enabled plugin setup through the normal isolated CLI runner. */
2
2
  import { discoverPlugins } from './plugin-loader.mjs';
3
- import { readPluginRecord } from './plugin-store.mjs';
3
+ import { pluginExecutionEnabled } from './plugin-state.mjs';
4
4
  import { runPluginCommand } from './plugin-runner.mjs';
5
5
 
6
- export async function setupInstalledPlugins({root=process.cwd(),plugins=discoverPlugins(root),run=runPluginCommand,record=readPluginRecord}={}) {
6
+ export async function setupInstalledPlugins({root=process.cwd(),plugins=discoverPlugins(root),run=runPluginCommand,activation=pluginExecutionEnabled}={}) {
7
7
  const results=[];
8
8
  for(const plugin of plugins) {
9
9
  const command=plugin.manifest?.runtime?.setup_command;
10
- if(!plugin.valid || !command || record(plugin)?.runtime_enabled===false)continue;
10
+ if(!plugin.valid || !command)continue;
11
+ // Setup follows the repository's own setting for a shared plugin.
12
+ if(!activation(plugin,{projectRoot:root}))continue;
11
13
  if(!plugin.manifest.cli?.subcommands?.some(operation=>operation.name===command)) {
12
14
  results.push({id:plugin.id,ok:false,error:'Runtime setup must name a declared CLI operation'});continue;
13
15
  }
@@ -71,13 +71,23 @@ export async function executePluginState(request) {
71
71
  }
72
72
  // A guard serializes state read/compare/commit across native worker processes.
73
73
  const lock = path.join(root, '.mutation-lock');
74
+ const guardTimeoutMs = request.guardTimeoutMs ?? 5000;
75
+ if (!Number.isInteger(guardTimeoutMs) || guardTimeoutMs < 1 || guardTimeoutMs > 5000) throw new Error('Invalid plugin guard timeout');
76
+ const deadline = performance.now() + guardTimeoutMs;
77
+ for (;;) {
74
78
  try {
75
79
  fs.mkdirSync(lock, { mode: 0o700 });
80
+ break;
76
81
  } catch (err) {
77
82
  // Distinguish a genuine concurrent mutation from an environment failure so
78
83
  // callers are not told the store is busy when it is actually inaccessible.
79
84
  if (err && err.code === 'EEXIST') {
80
- throw new Error('Plugin state mutation is busy; another mutation holds the guard');
85
+ const stat = fs.lstatSync(lock, { throwIfNoEntry: false });
86
+ if (!stat) continue;
87
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('Unsafe plugin mutation guard');
88
+ if (performance.now() >= deadline) throw new Error('Plugin state mutation is busy; timed out waiting for the guard');
89
+ await new Promise(resolve => setTimeout(resolve, 20));
90
+ continue;
81
91
  }
82
92
  if (err && (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'EROFS')) {
83
93
  throw new Error(
@@ -87,6 +97,7 @@ export async function executePluginState(request) {
87
97
  }
88
98
  throw new Error(`Plugin state mutation guard could not be created at ${lock}: ${err?.message || err}`);
89
99
  }
100
+ }
90
101
  try {
91
102
  const durableKeys = new FileIdempotencyStore(path.join(root, '.idempotency'));
92
103
  const intentHash = crypto.createHash('sha256').update(JSON.stringify({ path: relative, action, value, expectedVersion, format: request.format })).digest('hex');