total-recall-brain 3.28.2 → 3.30.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 (132) hide show
  1. package/bin/total-recall.mjs +24 -1
  2. package/docs/ARCHITECTURE.md +26 -18
  3. package/extension/background.js +259 -84
  4. package/extension/content-script.css +7 -13
  5. package/extension/content-script.js +252 -477
  6. package/extension/icons/icon-128.png +0 -0
  7. package/extension/icons/icon-16.png +0 -0
  8. package/extension/icons/icon-32.png +0 -0
  9. package/extension/icons/icon-48.png +0 -0
  10. package/extension/icons/mark.svg +6 -0
  11. package/extension/lib/brain-client.js +160 -73
  12. package/extension/lib/tokens.css +187 -0
  13. package/extension/lib/ui-helpers.js +229 -0
  14. package/extension/lib/ui-helpers.spec.mjs +94 -0
  15. package/extension/manifest.json +20 -5
  16. package/extension/options/options.css +64 -218
  17. package/extension/options/options.html +91 -43
  18. package/extension/options/options.js +208 -85
  19. package/extension/sidepanel/sidepanel.css +268 -970
  20. package/extension/sidepanel/sidepanel.html +200 -166
  21. package/extension/sidepanel/sidepanel.js +788 -942
  22. package/frontend/dist/assets/index-DGMNZi_1.js +5742 -0
  23. package/frontend/dist/index.html +1 -1
  24. package/frontend/src/api/plugins.ts +124 -92
  25. package/frontend/src/components/ResearchAgendaTab.spec.tsx +11 -0
  26. package/frontend/src/components/ResearchAgendaTab.tsx +19 -14
  27. package/frontend/src/pages/PluginsPage.spec.tsx +87 -16
  28. package/frontend/src/pages/PluginsPage.tsx +454 -1218
  29. package/package.json +2 -1
  30. package/plugins/code-quality/README.md +25 -0
  31. package/plugins/code-quality/cli.mjs +25 -0
  32. package/plugins/code-quality/plugin.json +17 -0
  33. package/plugins/git-sentinel/README.md +27 -0
  34. package/{scaffold/.agent/plugins → plugins}/git-sentinel/cli.mjs +8 -5
  35. package/plugins/git-sentinel/plugin.json +18 -0
  36. package/plugins/system-monitor/README.md +26 -0
  37. package/{scaffold/.agent/plugins → plugins}/system-monitor/cli.mjs +5 -4
  38. package/{scaffold/.agent/plugins → plugins}/system-monitor/generator.mjs +4 -5
  39. package/plugins/system-monitor/plugin.json +29 -0
  40. package/scaffold/.agent/skills/meta-harness/SKILL.md +4 -4
  41. package/scaffold/.agent/skills/plugins/SKILL.md +53 -0
  42. package/scaffold/.agent/skills/total-recall/scripts/brain-state.json +1 -0
  43. package/src/cli/agent.mjs +1 -1
  44. package/src/cli/app/index.mjs +493 -0
  45. package/src/cli/brain.mjs +33 -3
  46. package/src/cli/command.mjs +80 -41
  47. package/src/cli/harness.mjs +1 -1
  48. package/src/cli/init.mjs +3 -1
  49. package/src/cli/mesh.mjs +118 -12
  50. package/src/cli/plugin/create.mjs +144 -25
  51. package/src/cli/plugin/index.mjs +83 -53
  52. package/src/cli/plugin/install.mjs +10 -127
  53. package/src/cli/plugin/list.mjs +44 -44
  54. package/src/cli/plugin/remove.mjs +23 -30
  55. package/src/cli/plugin/scaffold-cmd.mjs +30 -0
  56. package/src/cli/plugin/search.mjs +108 -23
  57. package/src/cli/rebuild.mjs +37 -1
  58. package/src/cli/recall.mjs +17 -2
  59. package/src/cli/research.mjs +4 -6
  60. package/src/cli/share.mjs +2 -0
  61. package/src/core/app-deploy/apply.mjs +386 -0
  62. package/src/core/app-deploy/design-tokens.mjs +217 -0
  63. package/src/core/app-deploy/plan.mjs +249 -0
  64. package/src/core/app-deploy/resolve.mjs +295 -0
  65. package/src/core/app-deploy/source.mjs +337 -0
  66. package/src/core/app-deploy/standalone.mjs +248 -0
  67. package/src/core/app-deploy/upgrade.mjs +256 -0
  68. package/src/core/app-deploy/verify.mjs +213 -0
  69. package/src/core/bound-hosts.mjs +1 -1
  70. package/src/core/brain-registry.mjs +146 -0
  71. package/src/core/brain-state.json +1 -0
  72. package/src/core/daemon-loop.mjs +30 -26
  73. package/src/core/dream.mjs +5 -17
  74. package/src/core/fact-seeker.mjs +53 -368
  75. package/src/core/lan-discovery.mjs +3 -3
  76. package/src/core/mesh-access-sync.mjs +134 -0
  77. package/src/core/mesh-access.mjs +18 -3
  78. package/src/core/mesh-discover.mjs +173 -0
  79. package/src/core/mesh.mjs +131 -32
  80. package/src/core/optimizer.mjs +4 -41
  81. package/src/core/plugin-bundle.mjs +167 -0
  82. package/src/core/plugin-context.mjs +8 -13
  83. package/src/core/plugin-loader.mjs +272 -68
  84. package/src/core/plugin-peers.mjs +163 -0
  85. package/src/core/plugin-public.mjs +85 -0
  86. package/src/core/plugin-runner-child.mjs +24 -0
  87. package/src/core/plugin-runner.mjs +102 -0
  88. package/src/core/plugin-store.mjs +384 -0
  89. package/src/core/plugin-tasks.mjs +212 -0
  90. package/src/core/post-mortem.mjs +40 -9
  91. package/src/core/research-gate.mjs +182 -0
  92. package/src/core/research-queue.mjs +47 -7
  93. package/src/core/research-surface.mjs +211 -0
  94. package/src/core/research.mjs +25 -32
  95. package/src/core/scheduler.mjs +67 -153
  96. package/src/core/schema.mjs +129 -0
  97. package/src/core/search.mjs +12 -1
  98. package/src/core/secret-integration-research.mjs +12 -8
  99. package/src/core/secrets-store.mjs +32 -2
  100. package/src/core/session-watcher.mjs +6 -0
  101. package/src/core/surface.mjs +64 -3
  102. package/src/core/task-executors.mjs +8 -45
  103. package/src/core/vault-cache.mjs +70 -4
  104. package/src/server/api.mjs +21 -0
  105. package/src/server/rest.mjs +2 -0
  106. package/src/server/route-manifest.json +21 -5
  107. package/src/server/routes/collab.mjs +38 -11
  108. package/src/server/routes/extension.mjs +3 -2
  109. package/src/server/routes/memory.mjs +24 -2
  110. package/src/server/routes/mesh.mjs +7 -7
  111. package/src/server/routes/plugins-mesh.mjs +49 -0
  112. package/src/server/routes/plugins.mjs +91 -426
  113. package/src/server/routes/research.mjs +5 -2
  114. package/src/server/routes/share.mjs +2 -0
  115. package/src/server/tools.mjs +29 -2
  116. package/extension/icons/README.md +0 -27
  117. package/extension/lib/preconfigured.js +0 -5
  118. package/extension/popup/popup.css +0 -227
  119. package/extension/popup/popup.html +0 -50
  120. package/extension/popup/popup.js +0 -175
  121. package/frontend/dist/assets/index-U7o-qU99.js +0 -5742
  122. package/scaffold/.agent/plugins/.gitkeep +0 -0
  123. package/scaffold/.agent/plugins/code-quality/README.md +0 -16
  124. package/scaffold/.agent/plugins/code-quality/cli.mjs +0 -15
  125. package/scaffold/.agent/plugins/code-quality/plugin.json +0 -34
  126. package/scaffold/.agent/plugins/git-sentinel/README.md +0 -16
  127. package/scaffold/.agent/plugins/git-sentinel/plugin.json +0 -34
  128. package/scaffold/.agent/plugins/meta-harness/README.md +0 -16
  129. package/scaffold/.agent/plugins/meta-harness/cli.mjs +0 -34
  130. package/scaffold/.agent/plugins/meta-harness/plugin.json +0 -34
  131. package/scaffold/.agent/plugins/system-monitor/README.md +0 -16
  132. package/scaffold/.agent/plugins/system-monitor/plugin.json +0 -38
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Peer-facing plugin routes — how one Total Recall node shares plugins with
3
+ * another. Only reachable from the mesh (100.64.0.0/10) or loopback with the
4
+ * shared TR_MESH_SYNC_TOKEN, and only ever read-only: a peer can see and fetch
5
+ * what this node's owner marked shared, nothing more.
6
+ */
7
+ import { Router } from "express";
8
+ import { requireMeshSyncAuth } from "../../core/mesh-auth.mjs";
9
+ import { getMeshHostname } from "../../core/mesh.mjs";
10
+ import { listSharedPlugins, packSharedPlugin, packPublicPlugin } from "../../core/plugin-store.mjs";
11
+ import { serverError } from "./_shared.mjs";
12
+
13
+ const router = Router();
14
+ const ID_PATTERN = /^[a-z][a-z0-9-]{1,63}$/;
15
+
16
+ router.get("/api/mesh/plugins", requireMeshSyncAuth, (_req, res) => {
17
+ try {
18
+ const plugins = listSharedPlugins(process.cwd()).map(({ _plugin, ...rest }) => rest);
19
+ res.json({ node: { hostname: getMeshHostname() }, plugins });
20
+ } catch (err) {
21
+ serverError(res, err);
22
+ }
23
+ });
24
+
25
+ router.get("/api/mesh/plugins/:id/bundle", requireMeshSyncAuth, (req, res) => {
26
+ try {
27
+ if (!ID_PATTERN.test(req.params.id)) return res.status(400).json({ error: "Invalid plugin id" });
28
+ const bundle = packSharedPlugin(req.params.id, process.cwd());
29
+ if (!bundle) return res.status(404).json({ error: `Plugin ${req.params.id} is not shared by this node` });
30
+ res.json(bundle);
31
+ } catch (err) {
32
+ serverError(res, err);
33
+ }
34
+ });
35
+
36
+ // Shared bundles are public by owner choice. The recipient's URL carries a
37
+ // content hash pin; this route never lists plugins or executes their code.
38
+ router.get("/api/public/plugins/:id/bundle", (req, res) => {
39
+ try {
40
+ if (!ID_PATTERN.test(req.params.id)) return res.status(400).json({ error: "Invalid plugin id" });
41
+ const bundle = packPublicPlugin(req.params.id, process.cwd());
42
+ if (!bundle) return res.status(404).json({ error: "Plugin is not shared" });
43
+ res.set("Cache-Control", "no-store").json(bundle);
44
+ } catch (err) {
45
+ serverError(res, err);
46
+ }
47
+ });
48
+
49
+ export default router;
@@ -1,225 +1,70 @@
1
1
  import { Router } from "express";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import os from "node:os";
5
- import { spawnSync } from "node:child_process";
6
4
  import { requireAuth, requireScope } from "../auth.mjs";
7
- import { discoverPlugins, getPluginById, validatePluginManifest } from "../../core/plugin-loader.mjs";
5
+ import { getPluginById } from "../../core/plugin-loader.mjs";
6
+ import {
7
+ listInstalledPlugins,
8
+ listAvailableBundled,
9
+ describePlugin,
10
+ installPlugin,
11
+ uninstallPlugin,
12
+ setPluginShared
13
+ } from "../../core/plugin-store.mjs";
14
+ import { listPeerPlugins } from "../../core/plugin-peers.mjs";
15
+ import { runPluginCommand } from "../../core/plugin-runner.mjs";
8
16
  import { serverError, badRequest } from "./_shared.mjs";
9
17
 
10
18
  const router = Router();
11
19
 
12
- function getRatingsFilePath(projectRoot) {
13
- const root = projectRoot || process.cwd();
14
- return path.join(root, ".agent", "config", "plugin-ratings.json");
15
- }
16
-
17
- function loadRatings(projectRoot) {
18
- const filePath = getRatingsFilePath(projectRoot);
19
- if (!fs.existsSync(filePath)) return {};
20
- try {
21
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
22
- } catch {
23
- return {};
24
- }
25
- }
26
-
27
- function saveRatings(projectRoot, ratings) {
28
- const filePath = getRatingsFilePath(projectRoot);
29
- const dir = path.dirname(filePath);
30
- if (!fs.existsSync(dir)) {
31
- fs.mkdirSync(dir, { recursive: true });
32
- }
33
- fs.writeFileSync(filePath, JSON.stringify(ratings, null, 2), "utf8");
34
- }
35
-
36
- export const CURATED_CATALOG = [
37
- {
38
- id: "scientific-frontiers",
39
- name: "Scientific Frontiers Engine",
40
- version: "1.0.0",
41
- description: "Continuous scientific capability intelligence, curiosity-driven research graph weaver, and benchmark ledger.",
42
- tags: ["science", "benchmarks", "curiosity", "graph"],
43
- author: "Total Recall Ecosystem",
44
- sourceUrl: "https://github.com/gregiteen/scientific-frontiers-engine.git",
45
- rating: 4.9,
46
- reviewCount: 142,
47
- installCount: "3.2k",
48
- verified: true,
49
- isInstalled: false
50
- },
51
- {
52
- id: "meta-harness",
53
- name: "Meta Harness & Agent Orchestrator",
54
- version: "1.0.0",
55
- description: "Orchestrates and delegates tasks across connected AI harnesses (Antigravity, Claude Code, Codex, Ollama).",
56
- tags: ["agents", "multi-harness", "mesh", "delegation"],
57
- author: "Total Recall Ecosystem",
58
- sourceUrl: "./.agent/plugins/meta-harness",
59
- rating: 4.8,
60
- reviewCount: 98,
61
- installCount: "2.4k",
62
- verified: true,
63
- isInstalled: false
64
- },
65
- {
66
- id: "code-quality",
67
- name: "Code Quality & SSSS Conformance",
68
- version: "1.2.0",
69
- description: "Local-first code quality gates, SSSS schema verification, and invariant enforcement.",
70
- tags: ["quality", "conformance", "gates", "testing"],
71
- author: "Total Recall Ecosystem",
72
- sourceUrl: "./.agent/plugins/code-quality",
73
- rating: 5.0,
74
- reviewCount: 310,
75
- installCount: "8.2k",
76
- verified: true,
77
- isInstalled: false
78
- },
79
- {
80
- id: "system-monitor",
81
- name: "System Monitor & Telemetry",
82
- version: "1.0.0",
83
- description: "Real-time host resource metrics, memory watchdog, and dynamic system telemetry context generator.",
84
- tags: ["telemetry", "metrics", "monitor", "watchdog"],
85
- author: "Total Recall Ecosystem",
86
- sourceUrl: "./.agent/plugins/system-monitor",
87
- rating: 4.9,
88
- reviewCount: 56,
89
- installCount: "1.5k",
90
- verified: true,
91
- isInstalled: false
92
- },
93
- {
94
- id: "git-sentinel",
95
- name: "Git Sentinel & Repo Watchdog",
96
- version: "1.0.0",
97
- description: "Continuous repository auditor checking dirty worktrees, unpushed commits, and stale branches.",
98
- tags: ["git", "sentinel", "audit", "vcs"],
99
- author: "Total Recall Ecosystem",
100
- sourceUrl: "./.agent/plugins/git-sentinel",
101
- rating: 4.8,
102
- reviewCount: 73,
103
- installCount: "2.1k",
104
- verified: true,
105
- isInstalled: false
106
- },
107
- {
108
- id: "chrome-devtools",
109
- name: "Chrome DevTools Automation",
110
- version: "1.0.0",
111
- description: "Browser performance auditing, Core Web Vitals profiling, and accessibility testing tools.",
112
- tags: ["browser", "devtools", "testing", "mcp"],
113
- author: "Modern Web Guidance",
114
- sourceUrl: "https://github.com/total-recall-plugins/chrome-devtools.git",
115
- rating: 4.7,
116
- reviewCount: 84,
117
- installCount: "1.8k",
118
- verified: true,
119
- isInstalled: false
120
- }
121
- ];
20
+ // Plugins always resolve against the server's own project. Routes used to take
21
+ // a caller-supplied `root`, which let a request point discovery — and the
22
+ // runner — at any directory on disk.
23
+ const projectRoot = () => process.cwd();
122
24
 
123
25
  /**
124
26
  * GET /api/plugins
125
- * Returns list of installed plugins with their manifests, validation status, ratings, and capabilities.
27
+ * Installed plugins (project + global): manifest facts, provenance, content
28
+ * hash, sharing state and scheduled-task history. Nothing else.
126
29
  */
127
- router.get("/api/plugins", requireAuth, requireScope("config:read"), (req, res) => {
30
+ router.get("/api/plugins", requireAuth, requireScope("config:read"), (_req, res) => {
128
31
  try {
129
- const projectRoot = req.query?.root || process.cwd();
130
- const plugins = discoverPlugins(projectRoot);
131
- const userRatings = loadRatings(projectRoot);
132
-
133
- res.json({
134
- success: true,
135
- count: plugins.length,
136
- plugins: plugins.map((p) => {
137
- const userReview = userRatings[p.id] || null;
138
- const catalogMatch = CURATED_CATALOG.find((c) => c.id === p.id);
139
- const baseRating = catalogMatch?.rating || 4.8;
140
- const reviewCount = catalogMatch?.reviewCount || 12;
141
-
142
- return {
143
- id: p.id,
144
- name: p.manifest?.name || p.id,
145
- version: p.manifest?.version || "0.0.0",
146
- description: p.manifest?.description || "",
147
- valid: p.valid,
148
- errors: p.errors,
149
- dir: p.dir,
150
- rating: baseRating,
151
- reviewCount: userReview ? reviewCount + 1 : reviewCount,
152
- installCount: catalogMatch?.installCount || "1.0k",
153
- userRating: userReview?.rating || null,
154
- userReview: userReview?.review || null,
155
- categories: p.manifest?.ssss_schemas?.categories || [],
156
- tasks: p.manifest?.tasks || [],
157
- openwiki_hubs: p.manifest?.openwiki_hubs || [],
158
- tools: p.manifest?.tools || [],
159
- cli: p.manifest?.cli || null
160
- };
161
- })
162
- });
32
+ const plugins = listInstalledPlugins(projectRoot());
33
+ res.json({ success: true, count: plugins.length, plugins });
163
34
  } catch (err) {
164
35
  serverError(res, err);
165
36
  }
166
37
  });
167
38
 
168
39
  /**
169
- * GET /api/plugins/catalog
170
- * Returns curated discoverable plugins with ratings.
40
+ * GET /api/plugins/available
41
+ * Plugins bundled with this Total Recall package, installable on any node.
171
42
  */
172
- router.get("/api/plugins/catalog", requireAuth, requireScope("config:read"), (req, res) => {
43
+ router.get("/api/plugins/available", requireAuth, requireScope("config:read"), (_req, res) => {
173
44
  try {
174
- const projectRoot = req.query?.root || process.cwd();
175
- const installed = new Set(discoverPlugins(projectRoot).map((p) => p.id));
176
- const userRatings = loadRatings(projectRoot);
177
-
178
- const catalogWithStatus = CURATED_CATALOG.map((item) => ({
179
- ...item,
180
- isInstalled: installed.has(item.id),
181
- userRating: userRatings[item.id]?.rating || null
182
- }));
183
- res.json({
184
- success: true,
185
- catalog: catalogWithStatus
186
- });
45
+ res.json({ success: true, plugins: listAvailableBundled(projectRoot()) });
187
46
  } catch (err) {
188
47
  serverError(res, err);
189
48
  }
190
49
  });
191
50
 
192
51
  /**
193
- * POST /api/plugins/:id/rate
194
- * Submit or update a rating and review for an installed plugin.
52
+ * GET /api/plugins/peers
53
+ * Plugins shared by each mesh peer, queried live. Per-peer status is reported
54
+ * exactly as observed (ok, offline, unreachable, not_configured, unsupported, error).
195
55
  */
196
- router.post("/api/plugins/:id/rate", requireAuth, requireScope("config:write"), (req, res) => {
56
+ router.get("/api/plugins/peers", requireAuth, requireScope("config:read"), async (_req, res) => {
197
57
  try {
198
- const id = req.params.id;
199
- const { rating, review = "" } = req.body || {};
200
- const numRating = Number(rating);
201
-
202
- if (isNaN(numRating) || numRating < 1 || numRating > 5) {
203
- return badRequest(res, "Rating must be a number between 1 and 5");
58
+ const result = await listPeerPlugins();
59
+ const installed = new Map(listInstalledPlugins(projectRoot()).map((p) => [p.id, p]));
60
+ for (const peer of result.peers) {
61
+ for (const p of peer.plugins) {
62
+ const local = installed.get(p.id);
63
+ p.installed = !!local;
64
+ p.same_as_installed = !!local && local.sha256 === p.sha256;
65
+ }
204
66
  }
205
-
206
- const projectRoot = req.body?.projectRoot || process.cwd();
207
- const ratings = loadRatings(projectRoot);
208
-
209
- ratings[id] = {
210
- rating: Math.round(numRating * 10) / 10,
211
- review: typeof review === "string" ? review.trim() : "",
212
- updatedAt: new Date().toISOString()
213
- };
214
-
215
- saveRatings(projectRoot, ratings);
216
-
217
- res.json({
218
- success: true,
219
- message: "Rating saved for " + id,
220
- pluginId: id,
221
- userRating: ratings[id]
222
- });
67
+ res.json({ success: true, ...result });
223
68
  } catch (err) {
224
69
  serverError(res, err);
225
70
  }
@@ -227,288 +72,108 @@ router.post("/api/plugins/:id/rate", requireAuth, requireScope("config:write"),
227
72
 
228
73
  /**
229
74
  * POST /api/plugins/install
230
- * Installs or links a plugin from a local directory or git repository.
75
+ * body: { source: "<bundled id>" | "<share link>" | "peer:<host>/<id>" | "<git url>" | "<path>", link?, global? }
231
76
  */
232
- router.post("/api/plugins/install", requireAuth, requireScope("config:write"), (req, res) => {
77
+ router.post("/api/plugins/install", requireAuth, requireScope("config:write"), async (req, res) => {
233
78
  try {
234
- let { source, link = false, global: isGlobal = false, projectRoot } = req.body || {};
79
+ const { source, link = false, global: isGlobal = false } = req.body || {};
235
80
  if (!source || typeof source !== "string") {
236
- return badRequest(res, "Missing source path or git URL");
237
- }
238
-
239
- const matchedCatalog = CURATED_CATALOG.find((p) => p.id.toLowerCase() === source.toLowerCase());
240
- if (matchedCatalog) {
241
- source = matchedCatalog.sourceUrl;
242
- }
243
-
244
- const root = projectRoot || process.cwd();
245
- const pluginsBaseDir = isGlobal
246
- ? path.join(os.homedir(), ".agent", "plugins")
247
- : path.join(root, ".agent", "plugins");
248
-
249
- if (!fs.existsSync(pluginsBaseDir)) {
250
- fs.mkdirSync(pluginsBaseDir, { recursive: true });
251
- }
252
-
253
- const isGitUrl = source.startsWith("http://") ||
254
- source.startsWith("https://") ||
255
- source.startsWith("git@") ||
256
- source.endsWith(".git");
257
-
258
- if (isGitUrl) {
259
- const tempDir = path.join(os.tmpdir(), "tr-plugin-" + Date.now());
260
- const cloneRes = spawnSync("git", ["clone", "--depth", "1", source, tempDir], {
261
- encoding: "utf8"
262
- });
263
-
264
- if (cloneRes.status !== 0) {
265
- return badRequest(res, "Failed to clone repository: " + (cloneRes.stderr || "git clone failed"));
266
- }
267
-
268
- const manifestPath = path.join(tempDir, "plugin.json");
269
- if (!fs.existsSync(manifestPath)) {
270
- fs.rmSync(tempDir, { recursive: true, force: true });
271
- return badRequest(res, "Cloned repository does not contain a plugin.json manifest");
272
- }
273
-
274
- let manifest;
275
- try {
276
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
277
- } catch (err) {
278
- fs.rmSync(tempDir, { recursive: true, force: true });
279
- return badRequest(res, "Invalid plugin.json JSON: " + err.message);
280
- }
281
-
282
- const validation = validatePluginManifest(manifest);
283
- if (!validation.valid) {
284
- fs.rmSync(tempDir, { recursive: true, force: true });
285
- return badRequest(res, "Invalid plugin manifest: " + validation.errors.join(", "));
286
- }
287
-
288
- const destDir = path.join(pluginsBaseDir, manifest.id);
289
- if (fs.existsSync(destDir)) {
290
- fs.rmSync(tempDir, { recursive: true, force: true });
291
- return badRequest(res, "Plugin " + manifest.id + " is already installed at " + destDir);
292
- }
293
-
294
- fs.cpSync(tempDir, destDir, { recursive: true });
295
- fs.rmSync(tempDir, { recursive: true, force: true });
296
-
297
- return res.json({
298
- success: true,
299
- message: "Successfully installed plugin " + manifest.name,
300
- plugin: { id: manifest.id, name: manifest.name, version: manifest.version, dir: destDir }
301
- });
302
- }
303
-
304
- // Local directory install / link
305
- const absSource = path.isAbsolute(source) ? source : path.resolve(root, source);
306
- if (!fs.existsSync(absSource)) {
307
- return badRequest(res, "Source directory not found: " + absSource);
308
- }
309
-
310
- const manifestPath = path.join(absSource, "plugin.json");
311
- if (!fs.existsSync(manifestPath)) {
312
- return badRequest(res, "Directory " + absSource + " does not contain a plugin.json manifest");
313
- }
314
-
315
- let manifest;
316
- try {
317
- manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
318
- } catch (err) {
319
- return badRequest(res, "Invalid plugin.json JSON: " + err.message);
320
- }
321
-
322
- const validation = validatePluginManifest(manifest);
323
- if (!validation.valid) {
324
- return badRequest(res, "Invalid plugin manifest: " + validation.errors.join(", "));
81
+ return badRequest(res, "Missing plugin source (bundled id, share link, peer:<host>/<id>, git URL, or path)");
325
82
  }
326
-
327
- const destDir = path.join(pluginsBaseDir, manifest.id);
328
- if (fs.existsSync(destDir)) {
329
- return badRequest(res, "Plugin " + manifest.id + " is already installed");
330
- }
331
-
332
- if (link) {
333
- fs.symlinkSync(absSource, destDir, "junction");
334
- } else {
335
- fs.cpSync(absSource, destDir, { recursive: true });
336
- }
337
-
338
- return res.json({
83
+ const result = await installPlugin(source, { projectRoot: projectRoot(), link: !!link, global: !!isGlobal });
84
+ res.json({
339
85
  success: true,
340
- message: "Successfully " + (link ? "linked" : "installed") + " plugin " + manifest.name,
341
- plugin: { id: manifest.id, name: manifest.name, version: manifest.version, dir: destDir }
86
+ message: `Installed ${result.plugin.name} ${result.plugin.version} (${result.source.kind})`,
87
+ ...result
342
88
  });
343
89
  } catch (err) {
344
- serverError(res, err);
90
+ badRequest(res, err.message);
345
91
  }
346
92
  });
347
93
 
348
94
  /**
349
- * DELETE /api/plugins/:id
350
- * Removes or unlinks an installed plugin.
95
+ * POST /api/plugins/:id/share body: { shared: boolean }
96
+ * Offer (or stop offering) an installed plugin by direct link and mesh.
351
97
  */
352
- router.delete("/api/plugins/:id", requireAuth, requireScope("config:write"), (req, res) => {
98
+ router.post("/api/plugins/:id/share", requireAuth, requireScope("config:write"), async (req, res) => {
353
99
  try {
354
- const id = req.params.id;
355
- const isGlobal = req.query?.global === "true";
356
- const projectRoot = req.query?.root || process.cwd();
357
-
358
- const searchDirs = isGlobal
359
- ? [path.join(os.homedir(), ".agent", "plugins")]
360
- : [
361
- path.join(projectRoot, ".agent", "plugins"),
362
- path.join(os.homedir(), ".agent", "plugins")
363
- ];
364
-
365
- let targetPath = null;
366
- for (const dir of searchDirs) {
367
- const candidate = path.join(dir, id);
368
- if (fs.existsSync(candidate)) {
369
- targetPath = candidate;
370
- break;
371
- }
372
- }
373
-
374
- if (!targetPath) {
375
- return badRequest(res, "Plugin " + id + " not found");
376
- }
377
-
378
- const stat = fs.lstatSync(targetPath);
379
- if (stat.isSymbolicLink()) {
380
- fs.unlinkSync(targetPath);
381
- } else {
382
- fs.rmSync(targetPath, { recursive: true, force: true });
383
- }
100
+ const shared = req.body?.shared;
101
+ if (typeof shared !== "boolean") return badRequest(res, "Body must include shared: true|false");
102
+ const plugin = await setPluginShared(req.params.id, shared, { projectRoot: projectRoot() });
103
+ res.json({ success: true, plugin });
104
+ } catch (err) {
105
+ badRequest(res, err.message);
106
+ }
107
+ });
384
108
 
385
- res.json({
386
- success: true,
387
- message: "Plugin " + id + " uninstalled successfully",
388
- id
109
+ /**
110
+ * DELETE /api/plugins/:id[?global=true]
111
+ */
112
+ router.delete("/api/plugins/:id", requireAuth, requireScope("config:write"), async (req, res) => {
113
+ try {
114
+ const result = await uninstallPlugin(req.params.id, {
115
+ projectRoot: projectRoot(),
116
+ global: req.query?.global === "true" ? true : undefined
389
117
  });
118
+ res.json({ success: true, message: `Plugin ${result.id} removed`, ...result });
390
119
  } catch (err) {
391
- serverError(res, err);
120
+ badRequest(res, err.message);
392
121
  }
393
122
  });
394
123
 
395
124
  /**
396
125
  * GET /api/plugins/:id
397
- * Retrieve details of a specific plugin.
398
126
  */
399
127
  router.get("/api/plugins/:id", requireAuth, requireScope("config:read"), (req, res) => {
400
128
  try {
401
- const projectRoot = req.query?.root || process.cwd();
402
- const plugin = getPluginById(req.params.id, projectRoot);
403
- if (!plugin) {
404
- return badRequest(res, "Plugin " + req.params.id + " not found");
405
- }
406
- const userRatings = loadRatings(projectRoot);
407
- res.json({
408
- success: true,
409
- plugin: {
410
- id: plugin.id,
411
- name: plugin.manifest?.name || plugin.id,
412
- version: plugin.manifest?.version || "0.0.0",
413
- description: plugin.manifest?.description || "",
414
- valid: plugin.valid,
415
- errors: plugin.errors,
416
- dir: plugin.dir,
417
- manifest: plugin.manifest,
418
- userRating: userRatings[plugin.id]?.rating || null,
419
- userReview: userRatings[plugin.id]?.review || null
420
- }
421
- });
129
+ const plugin = getPluginById(req.params.id, projectRoot());
130
+ if (!plugin) return res.status(404).json({ success: false, error: `Plugin ${req.params.id} not found` });
131
+ res.json({ success: true, plugin: { ...describePlugin(plugin), manifest: plugin.manifest } });
422
132
  } catch (err) {
423
133
  serverError(res, err);
424
134
  }
425
135
  });
426
136
 
427
137
  /**
428
- * POST /api/plugins/:id/run
429
- * Executes a plugin CLI handler and returns captured stdout/stderr.
138
+ * POST /api/plugins/:id/run body: { subcommand?, args? }
139
+ * Runs the plugin's CLI handler in a child process. Executing plugin code is a
140
+ * write-level action.
430
141
  */
431
- router.post("/api/plugins/:id/run", requireAuth, requireScope("config:read"), async (req, res) => {
142
+ router.post("/api/plugins/:id/run", requireAuth, requireScope("config:write"), async (req, res) => {
432
143
  try {
433
- const { id } = req.params;
434
144
  const { subcommand = "", args = [] } = req.body || {};
435
- const projectRoot = req.body?.projectRoot || process.cwd();
436
- const plugin = getPluginById(id, projectRoot);
437
-
438
- if (!plugin) {
439
- return badRequest(res, "Plugin " + id + " not found");
440
- }
441
-
442
- if (!plugin.manifest?.cli?.handler) {
443
- return badRequest(res, "Plugin " + id + " does not declare a CLI handler");
444
- }
445
-
446
- const absHandler = path.resolve(plugin.dir, plugin.manifest.cli.handler);
447
- if (!fs.existsSync(absHandler)) {
448
- return badRequest(res, "Plugin CLI handler file not found: " + absHandler);
449
- }
450
-
451
- const logs = [];
452
- const origLog = console.log;
453
- const origError = console.error;
454
- console.log = (...a) => logs.push(a.map(x => typeof x === "object" ? JSON.stringify(x, null, 2) : String(x)).join(" "));
455
- console.error = (...a) => logs.push("[error] " + a.map(x => typeof x === "object" ? JSON.stringify(x, null, 2) : String(x)).join(" "));
456
-
457
- try {
458
- const handlerMod = await import(absHandler + "?t=" + Date.now());
459
- const argv = ["node", "total-recall", id, ...(subcommand ? [subcommand] : []), ...(Array.isArray(args) ? args : [])];
460
- if (handlerMod.run) {
461
- await handlerMod.run(argv);
462
- } else if (handlerMod.default) {
463
- await handlerMod.default(argv.slice(3));
464
- }
465
- } finally {
466
- console.log = origLog;
467
- console.error = origError;
145
+ if (typeof subcommand !== "string" || !Array.isArray(args) || args.some((a) => typeof a !== "string")) {
146
+ return badRequest(res, "subcommand must be a string and args an array of strings");
468
147
  }
148
+ const plugin = getPluginById(req.params.id, projectRoot());
149
+ if (!plugin) return res.status(404).json({ success: false, error: `Plugin ${req.params.id} not found` });
150
+ if (!plugin.valid) return badRequest(res, `Plugin ${plugin.id} has an invalid manifest: ${plugin.errors.join("; ")}`);
151
+ if (!plugin.manifest?.cli?.handler) return badRequest(res, `Plugin ${plugin.id} does not declare a CLI handler`);
469
152
 
470
- res.json({
471
- success: true,
472
- pluginId: id,
473
- output: logs.join("\n")
474
- });
153
+ const result = await runPluginCommand(plugin, { subcommand, args, cwd: projectRoot() });
154
+ res.json({ success: result.ok, pluginId: plugin.id, ...result });
475
155
  } catch (err) {
476
- serverError(res, err);
156
+ badRequest(res, err.message);
477
157
  }
478
158
  });
479
159
 
480
160
  /**
481
161
  * GET /api/plugins/:id/readme
482
- * Returns the README markdown content for the plugin.
483
162
  */
484
163
  router.get("/api/plugins/:id/readme", requireAuth, requireScope("config:read"), (req, res) => {
485
164
  try {
486
- const { id } = req.params;
487
- const projectRoot = req.query?.root || process.cwd();
488
- const plugin = getPluginById(id, projectRoot);
489
-
490
- if (!plugin) {
491
- return badRequest(res, "Plugin " + id + " not found");
492
- }
165
+ const plugin = getPluginById(req.params.id, projectRoot());
166
+ if (!plugin) return res.status(404).json({ success: false, error: `Plugin ${req.params.id} not found` });
493
167
 
494
- const candidates = [
495
- path.join(plugin.dir, "README.md"),
496
- path.join(plugin.dir, "readme.md")
497
- ];
498
-
499
- let content = "# " + (plugin.manifest?.name || id) + "\n\n" + (plugin.manifest?.description || "");
500
- for (const cand of candidates) {
501
- if (fs.existsSync(cand)) {
502
- content = fs.readFileSync(cand, "utf8");
168
+ let readme = `# ${plugin.manifest?.name || plugin.id}\n\n${plugin.manifest?.description || ""}`;
169
+ for (const name of ["README.md", "readme.md"]) {
170
+ const candidate = path.join(plugin.dir, name);
171
+ if (fs.existsSync(candidate)) {
172
+ readme = fs.readFileSync(candidate, "utf8");
503
173
  break;
504
174
  }
505
175
  }
506
-
507
- res.json({
508
- success: true,
509
- pluginId: id,
510
- readme: content
511
- });
176
+ res.json({ success: true, pluginId: plugin.id, readme });
512
177
  } catch (err) {
513
178
  serverError(res, err);
514
179
  }
@@ -30,9 +30,12 @@ researchRouter.get('/api/research', requireAuth, requireScope('memory:read'), (r
30
30
 
31
31
  researchRouter.post('/api/research', requireAuth, requireScope('memory:write'), (req, res) => {
32
32
  try {
33
- const { topic, priority, notes } = req.body || {};
33
+ const { topic, priority, notes, via } = req.body || {};
34
34
  if (!topic) return badRequest(res, 'topic is required');
35
- res.status(201).json(addToQueue({ topic, priority, notes }));
35
+ // Every REST request is a human asking (dashboard, extension, API client).
36
+ // `via` only labels where from; autonomous research never enters here.
37
+ const requested_via = ['dashboard', 'extension', 'chat', 'api'].includes(via) ? via : 'api';
38
+ res.status(201).json(addToQueue({ topic, priority, notes, origin: 'user', requested_via }));
36
39
  } catch (err) { serverError(res, err); }
37
40
  });
38
41