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.
- package/bin/total-recall-memory.mjs +0 -0
- package/bin/total-recall.mjs +9 -40
- package/docs/reference/cli-reference.md +65 -1
- package/frontend/dist/assets/{index-AGUes-VG.css → index-BG9NkVUy.css} +1 -1
- package/frontend/dist/assets/{index-Bt5iVPZj.js → index-BtuiZqwP.js} +20 -7
- package/frontend/dist/index.html +2 -2
- package/frontend/src/components/plugins/PluginPanel.tsx +31 -5
- package/frontend/src/components/plugins/card/CardEngine.tsx +15 -23
- package/frontend/src/components/plugins/card/CardScenes.tsx +52 -23
- package/frontend/src/components/plugins/card/card.css +2 -5
- package/frontend/src/components/plugins/card/contract.ts +13 -19
- package/frontend/src/components/plugins/card/format.ts +8 -1
- package/frontend/src/components/plugins/card/widgets.tsx +4 -35
- package/frontend/src/components/plugins/plugin-frame.ts +30 -3
- package/frontend/src/pages/PluginsPage.tsx +3 -2
- package/package.json +1 -1
- package/scaffold/.agent/skills/plugin/SKILL.md +110 -0
- package/scaffold/.agent/skills/plugin/evals/README.md +53 -0
- package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +42 -0
- package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +39 -0
- package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +32 -0
- package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +46 -0
- package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +55 -0
- package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +92 -0
- package/scaffold/.agent/skills/plugin/evals/run.mjs +118 -0
- package/scaffold/.agent/skills/plugin/prompts/01-function.md +29 -0
- package/scaffold/.agent/skills/plugin/prompts/02-surfaces.md +43 -0
- package/scaffold/.agent/skills/plugin/prompts/03-scaffold.md +31 -0
- package/scaffold/.agent/skills/plugin/prompts/04-cli.md +49 -0
- package/scaffold/.agent/skills/plugin/prompts/05-credentials.md +41 -0
- package/scaffold/.agent/skills/plugin/prompts/06-card.md +46 -0
- package/scaffold/.agent/skills/plugin/prompts/07-scaffold.md +52 -0
- package/scaffold/.agent/skills/plugin/prompts/08-verify.md +43 -0
- package/scaffold/.agent/skills/plugin/references/anti-patterns.md +123 -0
- package/scaffold/.agent/skills/plugin/references/card.md +90 -0
- package/scaffold/.agent/skills/plugin/references/scaffold.md +120 -0
- package/scaffold/.agent/skills/plugin/references/surfaces.md +206 -0
- package/scaffold/.agent/skills/plugin/scripts/record.mjs +360 -0
- package/scaffold/.agent/skills/plugin/subagents/card-designer.md +60 -0
- package/scaffold/.agent/skills/plugin/subagents/plugin-reviewer.md +55 -0
- package/scaffold/.agent/skills/plugin/subagents/scaffold-extractor.md +71 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +71 -1441
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +7 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +3 -44
- package/scaffold/.agent/skills/total-recall/plugins/auth/scripts/agent-login-pty.py +185 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +20 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/host-secrets-store.mjs +15 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-env-export.mjs +6 -4
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-remote-deploy.mjs +1 -0
- package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -24
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +0 -21
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +11 -0
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +1 -28
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/DESIGN.md +95 -13
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/ui/card.js +553 -340
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +0 -36
- package/scaffold/.agent/skills/total-recall/plugins/mesh/ui/card.js +383 -400
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +0 -1
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +0 -28
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +4 -4
- package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +0 -32
- package/scaffold/.agent/skills/total-recall/references/auth.md +3 -1
- package/scaffold/.agent/skills/total-recall/references/memory.md +4 -0
- package/scaffold/.agent/skills/total-recall/references/openwiki.md +2 -0
- package/scaffold/.agent/skills/total-recall/references/operations.md +12 -0
- package/src/cli/brain.mjs +4 -2
- package/src/cli/context-stats.mjs +104 -0
- package/src/cli/context.mjs +8 -0
- package/src/cli/daemon.mjs +10 -1
- package/src/cli/export.mjs +3 -2
- package/src/cli/ingest-okf.mjs +12 -15
- package/src/cli/init.mjs +0 -85
- package/src/cli/lint.mjs +3 -2
- package/src/cli/plugin/copies.mjs +68 -0
- package/src/cli/plugin/create.mjs +22 -24
- package/src/cli/plugin/index.mjs +49 -3
- package/src/cli/plugin/search.mjs +19 -2
- package/src/cli/rebuild.mjs +2 -0
- package/src/cli/remember.mjs +23 -0
- package/src/cli/status.mjs +10 -3
- package/src/cli/update.mjs +3 -2
- package/src/core/action-predictor.mjs +161 -0
- package/src/core/app-deploy/runtime-cli/node.mjs.tmpl +20 -3
- package/src/core/app-deploy/source.mjs +16 -9
- package/src/core/automatic-brain-updates.mjs +6 -3
- package/src/core/brain-registry.mjs +19 -2
- package/src/core/brain-state.mjs +23 -0
- package/src/core/cli-commands.mjs +1 -0
- package/src/core/cli-maintenance.mjs +1 -1
- package/src/core/cli-semantic-index.mjs +1 -1
- package/src/core/command-surface.mjs +3 -3
- package/src/core/composed-command-resolution.mjs +21 -0
- package/src/core/config.mjs +84 -0
- package/src/core/confined-memory.mjs +93 -0
- package/src/core/context-telemetry.mjs +214 -0
- package/src/core/daemon-control.mjs +14 -43
- package/src/core/fast-recall.mjs +1 -1
- package/src/core/memory-brain.mjs +2 -0
- package/src/core/plugin-activation.mjs +38 -6
- package/src/core/plugin-bundle.mjs +54 -0
- package/src/core/plugin-catalog.mjs +1 -1
- package/src/core/plugin-context.mjs +1 -1
- package/src/core/plugin-contracts/card.mjs +69 -37
- package/src/core/plugin-loader.mjs +5 -6
- package/src/core/plugin-runner.mjs +13 -6
- package/src/core/plugin-runtime.mjs +5 -4
- package/src/core/plugin-setup.mjs +5 -3
- package/src/core/plugin-state-store.mjs +12 -1
- package/src/core/plugin-state.mjs +46 -5
- package/src/core/plugin-store.mjs +78 -3
- package/src/core/plugin-tasks.mjs +3 -1
- package/src/core/plugin-updates.mjs +117 -0
- package/src/core/plugin-vendor.mjs +10 -4
- package/src/core/project-brain.mjs +14 -41
- package/src/core/project-id.mjs +4 -1
- package/src/core/required-plugins.mjs +151 -13
- package/src/core/skills-registry.mjs +27 -3
- package/src/core/ssss-kernel-bridge.mjs +3 -1
- package/src/core/startup-health.mjs +66 -16
- package/src/core/surface.mjs +51 -44
- package/src/core/testing/plugin-fixture.mjs +69 -0
- package/src/core/vault-files.mjs +13 -0
- package/src/core/vault.mjs +4 -30
- package/src/server/index.mjs +13 -1
- package/src/server/invoke-decision-child.mjs +49 -0
- package/src/server/invoke-execution.mjs +87 -0
- package/src/server/invoke-policy.mjs +64 -0
- package/src/server/invoke-read-child.mjs +26 -0
- package/src/server/invoke-targets.mjs +49 -0
- package/src/server/invoke-write-child.mjs +19 -0
- package/src/server/routes/invoke.mjs +314 -0
- package/src/server/routes/plugins.mjs +1 -1
- package/scaffold/.agent/skills/plugins/SKILL.md +0 -57
- package/scaffold/.agent/skills/plugins/references/card-design.md +0 -55
- package/scaffold/.agent/skills/plugins/references/distribution.md +0 -23
- package/scaffold/.agent/skills/plugins/references/manifest-contract.md +0 -90
- package/scaffold/.agent/skills/plugins/references/optimized/index.md +0 -9
- package/scaffold/.agent/skills/plugins/references/optimized/manifest.json +0 -35
- package/scaffold/.agent/skills/plugins/references/optimized/original.md +0 -58
- package/scaffold/.agent/skills/plugins/references/optimized/section-01.md +0 -5
- package/scaffold/.agent/skills/plugins/references/optimized/section-02.md +0 -25
- package/scaffold/.agent/skills/plugins/references/optimized/section-03.md +0 -12
- package/scaffold/.agent/skills/plugins/references/optimized/section-04.md +0 -7
- package/scaffold/.agent/skills/plugins/references/optimized/section-05.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/README.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/architecture.md +0 -5
- package/scaffold/.agent/skills/total-recall/openwiki/ide.md +0 -10
- package/scaffold/.agent/skills/total-recall/openwiki/memory.md +0 -11
- package/scaffold/.agent/skills/total-recall/openwiki/secrets.md +0 -7
- package/scaffold/.agent/skills/total-recall/openwiki/skills.md +0 -7
- package/scaffold/openwiki/README.md +0 -10
- package/scaffold/openwiki/architecture.md +0 -5
- package/scaffold/openwiki/ide.md +0 -10
- package/scaffold/openwiki/memory.md +0 -11
- package/scaffold/openwiki/secrets.md +0 -7
- package/scaffold/openwiki/skills.md +0 -7
- package/src/core/okf-adapter.mjs +0 -559
- package/templates/openwiki/README.md +0 -10
- package/templates/openwiki/architecture.md +0 -5
- package/templates/openwiki/ide.md +0 -10
- package/templates/openwiki/memory.md +0 -11
- package/templates/openwiki/secrets.md +0 -7
- 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 {
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/core/fast-recall.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
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 (
|
|
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 >
|
|
99
|
-
const partial=buf.subarray(0,
|
|
100
|
-
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
-
|
|
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');
|