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
@@ -1,1328 +1,564 @@
1
- import { useState, useEffect, useMemo, useCallback } from "react"
2
- import {
3
- fetchPlugins,
4
- fetchPluginCatalog,
5
- installPlugin,
6
- removePlugin,
7
- ratePlugin,
1
+ import { useState, useEffect, useMemo, useCallback, type CSSProperties, type ReactNode } from "react"
2
+ import { useSearchParams } from "react-router-dom"
3
+ import {
4
+ fetchPlugins,
5
+ fetchBundledPlugins,
6
+ fetchPeerPlugins,
7
+ installPlugin,
8
+ removePlugin,
9
+ setPluginShared,
8
10
  runPluginCommand,
9
11
  fetchPluginReadme,
10
- type PluginInfo,
11
- type CatalogPlugin
12
+ type PluginInfo,
13
+ type BundledPlugin,
14
+ type PeersResponse,
15
+ type PeerNode
12
16
  } from "../api"
13
- import { useSearchParams } from "react-router-dom"
17
+ import { renderMarkdown } from "../components/MarkdownUtils"
14
18
 
15
19
  interface PluginsPageProps {
16
20
  activeBrainId?: string | null
17
21
  }
18
22
 
19
- function StarRatingDisplay({ rating, reviewCount, installCount }: { rating: number; reviewCount?: number; installCount?: string }) {
20
- const stars = [1, 2, 3, 4, 5]
21
- return (
22
- <div style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "12px" }}>
23
- <div style={{ display: "flex", color: "#fbbf24" }}>
24
- {stars.map((s) => (
25
- <span key={s} style={{ opacity: s <= Math.round(rating) ? 1 : 0.35 }}>
26
- ★
27
- </span>
28
- ))}
29
- </div>
30
- <span style={{ fontWeight: "700", color: "var(--text-primary)" }}>{rating.toFixed(1)}</span>
31
- {reviewCount !== undefined && (
32
- <span style={{ color: "var(--text-tertiary)" }}>({reviewCount})</span>
33
- )}
34
- {installCount && (
35
- <>
36
- <span style={{ color: "var(--text-tertiary)" }}>•</span>
37
- <span style={{ color: "var(--text-secondary)" }}>{installCount} installs</span>
38
- </>
39
- )}
40
- </div>
41
- )
23
+ type Tab = "installed" | "bundled" | "mesh"
24
+ type DetailTab = "run" | "readme" | "details"
25
+ type Alert = { type: "success" | "error"; message: string } | null
26
+
27
+ const PEER_STATUS_LABEL: Record<PeerNode["status"], string> = {
28
+ ok: "online",
29
+ offline: "offline",
30
+ unreachable: "unreachable",
31
+ not_configured: "mesh sync not configured",
32
+ unsupported: "no plugin sharing (older version)",
33
+ error: "error"
42
34
  }
43
35
 
44
- function InteractiveRater({
45
- pluginId,
46
- currentRating,
47
- onRated
48
- }: {
49
- pluginId: string;
50
- currentRating?: number | null;
51
- onRated: (score: number) => void
52
- }) {
53
- const [hoverRating, setHoverRating] = useState<number | null>(null)
54
- const [submitting, setSubmitting] = useState(false)
36
+ const mono: CSSProperties = { fontFamily: "var(--font-mono)", fontSize: "12px" }
37
+ const muted: CSSProperties = { color: "var(--text-secondary)", fontSize: "13px", lineHeight: 1.5 }
55
38
 
56
- const handleRate = async (score: number) => {
57
- setSubmitting(true)
58
- const res = await ratePlugin(pluginId, score)
59
- setSubmitting(false)
60
- if (res.success) {
61
- onRated(score)
62
- }
39
+ function shortHash(sha: string | null | undefined) {
40
+ return sha ? `${sha.slice(0, 12)}…` : "—"
41
+ }
42
+
43
+ function formatBytes(n: number | null | undefined) {
44
+ if (n === null || n === undefined) return "—"
45
+ if (n < 1024) return `${n} B`
46
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
47
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
48
+ }
49
+
50
+ function sourceLabel(p: PluginInfo) {
51
+ switch (p.source.kind) {
52
+ case "peer": return `from ${p.source.peer_hostname || "a peer"}`
53
+ case "bundled": return "bundled"
54
+ case "git": return "git"
55
+ case "public": return "direct link"
56
+ case "link": return "linked folder"
57
+ default: return "local folder"
63
58
  }
59
+ }
64
60
 
65
- const activeScore = hoverRating !== null ? hoverRating : (currentRating || 0)
61
+ function UseCaseChips({ useCases }: { useCases: string[] }) {
62
+ if (!useCases.length) return null
63
+ return (
64
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
65
+ {useCases.map((u) => (
66
+ <span key={u} className="badge" style={{ fontSize: "11px" }}>{u}</span>
67
+ ))}
68
+ </div>
69
+ )
70
+ }
66
71
 
72
+ function Fact({ label, children }: { label: string; children: ReactNode }) {
67
73
  return (
68
- <div style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "12px" }}>
69
- <span style={{ color: "var(--text-tertiary)", fontSize: "11px" }}>Rate:</span>
70
- <div style={{ display: "flex", gap: "2px", cursor: submitting ? "wait" : "pointer" }}>
71
- {[1, 2, 3, 4, 5].map((star) => (
72
- <span
73
- key={star}
74
- onMouseEnter={() => setHoverRating(star)}
75
- onMouseLeave={() => setHoverRating(null)}
76
- onClick={() => handleRate(star)}
77
- style={{
78
- fontSize: "14px",
79
- color: star <= activeScore ? "#fbbf24" : "var(--text-tertiary)",
80
- transition: "transform 0.1s ease",
81
- transform: hoverRating === star ? "scale(1.2)" : "scale(1)"
82
- }}
83
- title={`Rate ${star} star${star > 1 ? "s" : ""}`}
84
- >
85
- ★
86
- </span>
87
- ))}
88
- </div>
89
- {currentRating && (
90
- <span style={{ color: "var(--success)", fontSize: "11px", fontWeight: "600" }}>
91
- (You rated {currentRating}★)
92
- </span>
93
- )}
74
+ <div style={{ display: "flex", gap: "12px", fontSize: "13px", padding: "6px 0", borderBottom: "1px solid var(--border)" }}>
75
+ <span style={{ width: "132px", flexShrink: 0, color: "var(--text-tertiary)" }}>{label}</span>
76
+ <span style={{ color: "var(--text-primary)", minWidth: 0, overflowWrap: "anywhere" }}>{children}</span>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ function Empty({ title, children }: { title: string; children?: ReactNode }) {
82
+ return (
83
+ <div className="card" style={{ textAlign: "center", padding: "40px 24px" }}>
84
+ <div style={{ fontWeight: 600, marginBottom: "6px" }}>{title}</div>
85
+ {children && <div style={muted}>{children}</div>}
94
86
  </div>
95
87
  )
96
88
  }
97
89
 
98
90
  export default function PluginsPage({ activeBrainId }: PluginsPageProps) {
99
91
  const [searchParams, setSearchParams] = useSearchParams()
100
- const [plugins, setPlugins] = useState<PluginInfo[]>([])
101
- const [catalog, setCatalog] = useState<CatalogPlugin[]>([])
92
+ const [tab, setTab] = useState<Tab>("installed")
93
+ const [installed, setInstalled] = useState<PluginInfo[]>([])
94
+ const [bundled, setBundled] = useState<BundledPlugin[]>([])
95
+ const [peers, setPeers] = useState<PeersResponse | null>(null)
96
+ const [peersLoading, setPeersLoading] = useState(false)
102
97
  const [loading, setLoading] = useState(true)
103
- const [activeTab, setActiveTab] = useState<"installed" | "catalog">("installed")
104
- const [searchQuery, setSearchQuery] = useState("")
105
- const [sortBy, setSortBy] = useState<"rating" | "reviews" | "name">("rating")
106
- const [installModalOpen, setInstallModalOpen] = useState(false)
107
-
108
- // Plugin Detail & Runner Modal State
109
- const [detailModalPlugin, setDetailModalPlugin] = useState<PluginInfo | null>(null)
110
- const [detailTab, setDetailTab] = useState<"runner" | "readme" | "schemas" | "tasks" | "manifest">("runner")
111
- const [subcommand, setSubcommand] = useState("")
112
- const [customArgs, setCustomArgs] = useState("")
113
- const [executing, setExecuting] = useState(false)
114
- const [consoleOutput, setConsoleOutput] = useState("")
115
- const [readmeText, setReadmeText] = useState("")
116
- const [readmeLoading, setReadmeLoading] = useState(false)
98
+ const [query, setQuery] = useState("")
99
+ const [useCase, setUseCase] = useState("")
100
+ const [busy, setBusy] = useState<string | null>(null)
101
+ const [alert, setAlert] = useState<Alert>(null)
117
102
 
118
- // Install Form State
103
+ const [installOpen, setInstallOpen] = useState(false)
119
104
  const [installSource, setInstallSource] = useState("")
120
105
  const [installLink, setInstallLink] = useState(false)
121
106
  const [installGlobal, setInstallGlobal] = useState(false)
122
- const [installing, setInstalling] = useState(false)
123
- const [alert, setAlert] = useState<{ type: "success" | "error"; message: string } | null>(null)
124
107
 
125
- const loadData = useCallback(async () => {
108
+ const [detail, setDetail] = useState<PluginInfo | null>(null)
109
+ const [detailTab, setDetailTab] = useState<DetailTab>("run")
110
+ const [subcommand, setSubcommand] = useState("")
111
+ const [args, setArgs] = useState("")
112
+ const [output, setOutput] = useState<{ text: string; ok: boolean } | null>(null)
113
+ const [readme, setReadme] = useState("")
114
+
115
+ const loadLocal = useCallback(async () => {
126
116
  setLoading(true)
127
117
  try {
128
- const [installedList, catalogList] = await Promise.all([
129
- fetchPlugins(),
130
- fetchPluginCatalog()
131
- ])
132
- setPlugins(installedList)
133
- setCatalog(catalogList)
118
+ const [i, b] = await Promise.all([fetchPlugins(), fetchBundledPlugins()])
119
+ setInstalled(i)
120
+ setBundled(b)
134
121
  } finally {
135
122
  setLoading(false)
136
123
  }
137
124
  }, [])
138
125
 
139
- useEffect(() => {
140
- loadData()
141
- }, [loadData, activeBrainId])
142
-
143
- const openDetail = async (p: PluginInfo, tab: "runner" | "readme" | "schemas" | "tasks" | "manifest" = "runner") => {
144
- setDetailModalPlugin(p)
145
- const subcommands = (p.manifest as any)?.cli?.subcommands as Array<{ name: string; description: string }> | undefined
146
- const defaultSub = subcommands?.[0]?.name || (p.cli?.command === "git-sentinel" ? "audit" : "status")
147
- setSubcommand(defaultSub)
148
- setCustomArgs("")
149
- setConsoleOutput("")
150
- setDetailTab(tab)
151
- setReadmeLoading(true)
126
+ const loadPeers = useCallback(async () => {
127
+ setPeersLoading(true)
152
128
  try {
153
- const rm = await fetchPluginReadme(p.id)
154
- setReadmeText(rm)
129
+ setPeers(await fetchPeerPlugins())
155
130
  } finally {
156
- setReadmeLoading(false)
131
+ setPeersLoading(false)
157
132
  }
158
- }
159
-
160
- const closeDetail = () => {
161
- setDetailModalPlugin(null)
162
- if (searchParams.has("id")) {
163
- const next = new URLSearchParams(searchParams)
164
- next.delete("id")
165
- setSearchParams(next)
166
- }
167
- }
168
-
169
- const handleExecute = async () => {
170
- if (!detailModalPlugin) return
171
- setExecuting(true)
172
- const args = customArgs.trim() ? customArgs.trim().split(/\s+/) : []
173
- const res = await runPluginCommand(detailModalPlugin.id, subcommand, args)
174
- setExecuting(false)
175
- if (res.success) {
176
- setConsoleOutput(res.output || "(Command completed with no output)")
177
- } else {
178
- setConsoleOutput(`❌ Error: ${res.error || "Failed to execute command"}`)
179
- }
180
- }
133
+ }, [])
181
134
 
182
135
  useEffect(() => {
183
- const idParam = searchParams.get("id")
184
- if (idParam && plugins.length > 0) {
185
- const match = plugins.find(p => p.id === idParam)
186
- if (match && (!detailModalPlugin || detailModalPlugin.id !== idParam)) {
187
- openDetail(match, "runner")
188
- }
189
- }
190
- }, [searchParams, plugins])
136
+ loadLocal()
137
+ }, [loadLocal, activeBrainId])
191
138
 
192
- const handleInstallSubmit = async (e?: React.FormEvent) => {
193
- if (e) e.preventDefault()
194
- if (!installSource.trim()) return
195
-
196
- setInstalling(true)
139
+ useEffect(() => {
140
+ if (tab === "mesh" && peers === null && !peersLoading) loadPeers()
141
+ }, [tab, peers, peersLoading, loadPeers])
142
+
143
+ const allUseCases = useMemo(() => {
144
+ const set = new Set<string>()
145
+ for (const p of installed) p.use_cases.forEach((u) => set.add(u))
146
+ for (const p of bundled) p.use_cases.forEach((u) => set.add(u))
147
+ for (const n of peers?.peers || []) n.plugins.forEach((p) => p.use_cases.forEach((u) => set.add(u)))
148
+ return Array.from(set).sort()
149
+ }, [installed, bundled, peers])
150
+
151
+ const matches = useCallback((p: { id: string; name: string; description: string; use_cases: string[] }) => {
152
+ if (useCase && !p.use_cases.includes(useCase)) return false
153
+ const q = query.trim().toLowerCase()
154
+ if (!q) return true
155
+ return [p.id, p.name, p.description, ...p.use_cases].some((v) => v.toLowerCase().includes(q))
156
+ }, [query, useCase])
157
+
158
+ const doInstall = async (source: string, opts: { link?: boolean; global?: boolean } = {}) => {
159
+ setBusy(source)
197
160
  setAlert(null)
198
-
199
- const res = await installPlugin({
200
- source: installSource.trim(),
201
- link: installLink,
202
- global: installGlobal
203
- })
204
-
205
- setInstalling(false)
206
-
161
+ const res = await installPlugin({ source, ...opts })
162
+ setBusy(null)
207
163
  if (res.success) {
208
- setAlert({ type: "success", message: res.message || "Plugin installed successfully" })
209
- setInstallSource("")
210
- setInstallModalOpen(false)
211
- loadData()
212
- } else {
213
- setAlert({ type: "error", message: res.error || "Failed to install plugin" })
164
+ setAlert({ type: "success", message: res.message || `Installed ${source}` })
165
+ await loadLocal()
166
+ if (peers) loadPeers()
167
+ return true
214
168
  }
169
+ setAlert({ type: "error", message: res.error || `Could not install ${source}` })
170
+ return false
215
171
  }
216
172
 
217
- const handleCatalogInstall = async (item: CatalogPlugin) => {
218
- setInstalling(true)
219
- setAlert(null)
220
- const res = await installPlugin({
221
- source: item.sourceUrl,
222
- link: item.sourceUrl.startsWith("./") || item.sourceUrl.startsWith("local:"),
223
- global: false
224
- })
225
- setInstalling(false)
226
-
173
+ const doRemove = async (p: PluginInfo) => {
174
+ if (!window.confirm(`Remove "${p.name}" (${p.scope})?`)) return
175
+ setBusy(p.id)
176
+ const res = await removePlugin(p.id, p.scope === "global")
177
+ setBusy(null)
178
+ setAlert(res.success ? { type: "success", message: `Removed ${p.name}` } : { type: "error", message: res.error || "Remove failed" })
227
179
  if (res.success) {
228
- setAlert({ type: "success", message: `Installed ${item.name} successfully` })
229
- loadData()
230
- } else {
231
- setAlert({ type: "error", message: res.error || `Failed to install ${item.name}` })
180
+ if (detail?.id === p.id) closeDetail()
181
+ loadLocal()
232
182
  }
233
183
  }
234
184
 
235
- const handleUninstall = async (plugin: PluginInfo) => {
236
- if (!window.confirm(`Are you sure you want to uninstall "${plugin.name}"?`)) return
237
-
238
- const res = await removePlugin(plugin.id)
239
- if (res.success) {
240
- setAlert({ type: "success", message: `Uninstalled ${plugin.name}` })
241
- loadData()
242
- } else {
243
- setAlert({ type: "error", message: res.error || "Failed to uninstall plugin" })
185
+ const doShare = async (p: PluginInfo, shared: boolean) => {
186
+ setBusy(p.id)
187
+ const res = await setPluginShared(p.id, shared)
188
+ setBusy(null)
189
+ if (!res.success) {
190
+ setAlert({ type: "error", message: res.error || "Could not change sharing" })
191
+ return
244
192
  }
193
+ if (res.plugin && detail?.id === p.id) setDetail(res.plugin)
194
+ setAlert({ type: "success", message: shared ? `${p.name} is shared. Its public link is in the details.` : `${p.name} is no longer shared` })
195
+ loadLocal()
245
196
  }
246
197
 
247
- const handlePluginRated = (id: string, score: number) => {
248
- setPlugins(prev => prev.map(p => p.id === id ? { ...p, userRating: score } : p))
249
- setAlert({ type: "success", message: "Thank you! Your rating has been recorded." })
198
+ const openDetail = async (p: PluginInfo) => {
199
+ setDetail(p)
200
+ setDetailTab(p.cli ? "run" : "details")
201
+ setSubcommand(p.cli?.subcommands?.[0]?.name || "")
202
+ setArgs("")
203
+ setOutput(null)
204
+ setReadme("")
205
+ setReadme(await fetchPluginReadme(p.id))
250
206
  }
251
207
 
252
- const filteredInstalled = useMemo(() => {
253
- const q = searchQuery.toLowerCase().trim()
254
- let list = plugins.filter(p =>
255
- p.name.toLowerCase().includes(q) ||
256
- p.id.toLowerCase().includes(q) ||
257
- p.description.toLowerCase().includes(q) ||
258
- (p.cli?.command && p.cli.command.toLowerCase().includes(q))
259
- )
260
-
261
- if (sortBy === "rating") {
262
- list.sort((a, b) => (b.rating || 0) - (a.rating || 0))
263
- } else if (sortBy === "reviews") {
264
- list.sort((a, b) => (b.reviewCount || 0) - (a.reviewCount || 0))
265
- } else {
266
- list.sort((a, b) => a.name.localeCompare(b.name))
208
+ const closeDetail = () => {
209
+ setDetail(null)
210
+ if (searchParams.has("id")) {
211
+ const next = new URLSearchParams(searchParams)
212
+ next.delete("id")
213
+ setSearchParams(next)
267
214
  }
268
- return list
269
- }, [plugins, searchQuery, sortBy])
215
+ }
270
216
 
271
- const filteredCatalog = useMemo(() => {
272
- const q = searchQuery.toLowerCase().trim()
273
- let list = catalog.filter(c =>
274
- c.name.toLowerCase().includes(q) ||
275
- c.id.toLowerCase().includes(q) ||
276
- c.description.toLowerCase().includes(q) ||
277
- c.tags.some(t => t.toLowerCase().includes(q))
278
- )
217
+ useEffect(() => {
218
+ const id = searchParams.get("id")
219
+ if (!id || detail?.id === id) return
220
+ const match = installed.find((p) => p.id === id)
221
+ if (match) openDetail(match)
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [searchParams, installed])
224
+
225
+ // Keep an open detail panel in sync after share/remove/reload.
226
+ useEffect(() => {
227
+ if (!detail) return
228
+ const fresh = installed.find((p) => p.id === detail.id)
229
+ if (fresh && fresh !== detail) setDetail(fresh)
230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
231
+ }, [installed])
232
+
233
+ const runCommand = async () => {
234
+ if (!detail) return
235
+ setBusy(`run:${detail.id}`)
236
+ const res = await runPluginCommand(detail.id, subcommand, args.trim() ? args.trim().split(/\s+/) : [])
237
+ setBusy(null)
238
+ if (res.error) setOutput({ text: res.error, ok: false })
239
+ else setOutput({ text: (res.output || "").trim() || "(no output)", ok: res.success })
240
+ }
279
241
 
280
- if (sortBy === "rating") {
281
- list.sort((a, b) => (b.rating || 0) - (a.rating || 0))
282
- } else if (sortBy === "reviews") {
283
- list.sort((a, b) => (b.reviewCount || 0) - (a.reviewCount || 0))
284
- } else {
285
- list.sort((a, b) => a.name.localeCompare(b.name))
286
- }
287
- return list
288
- }, [catalog, searchQuery, sortBy])
242
+ const filteredInstalled = installed.filter(matches)
243
+ const filteredBundled = bundled.filter(matches)
244
+
245
+ const tabButton = (id: Tab, label: string, count?: number) => (
246
+ <button
247
+ key={id}
248
+ role="tab"
249
+ aria-selected={tab === id}
250
+ className={`btn btn-sm ${tab === id ? "btn-primary" : "btn-ghost"}`}
251
+ onClick={() => setTab(id)}
252
+ >
253
+ {label}
254
+ {count !== undefined && <span style={{ opacity: 0.75, fontWeight: 500 }}>{count}</span>}
255
+ </button>
256
+ )
289
257
 
290
258
  return (
291
- <div style={{ padding: "32px 40px", maxWidth: "1280px", margin: "0 auto", color: "var(--text-primary)" }}>
292
- {/* Header */}
293
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "28px" }}>
259
+ <div className="page" style={{ maxWidth: "1200px", margin: "0 auto", width: "100%" }}>
260
+ <div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "16px", flexWrap: "wrap" }}>
294
261
  <div>
295
- <div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "6px" }}>
296
- <h1 style={{ fontSize: "28px", fontWeight: "700", margin: 0, letterSpacing: "-0.02em" }}>
297
- Plugins & Extensions
298
- </h1>
299
- <span style={{
300
- background: "var(--accent-muted)",
301
- color: "var(--accent-hover)",
302
- fontSize: "12px",
303
- fontWeight: "600",
304
- padding: "3px 10px",
305
- borderRadius: "20px",
306
- border: "1px solid var(--border-accent)"
307
- }}>
308
- {plugins.length} Installed
309
- </span>
310
- </div>
311
- <p style={{ margin: 0, color: "var(--text-secondary)", fontSize: "14px", lineHeight: "1.5" }}>
312
- Modular autonomous engines, scholarly daemons, and custom SSSS graph schemas extending your Total Recall brain.
262
+ <h1>Plugins</h1>
263
+ <p>
264
+ Plugins shape Total Recall for a particular use — memory categories, agent context, commands and scheduled jobs.
265
+ Share them directly with other users through a hash-pinned HTTPS link. Your mesh peers can use the same shared plugins.
313
266
  </p>
314
267
  </div>
315
-
316
- <button
317
- onClick={() => { setInstallModalOpen(true); setAlert(null); }}
318
- style={{
319
- display: "flex",
320
- alignItems: "center",
321
- gap: "8px",
322
- background: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)",
323
- color: "#ffffff",
324
- border: "none",
325
- borderRadius: "var(--radius-md)",
326
- padding: "10px 18px",
327
- fontSize: "14px",
328
- fontWeight: "600",
329
- cursor: "pointer",
330
- boxShadow: "0 4px 14px var(--accent-glow)",
331
- transition: "all 0.15s ease"
332
- }}
333
- onMouseOver={(e) => (e.currentTarget.style.filter = "brightness(1.1)")}
334
- onMouseOut={(e) => (e.currentTarget.style.filter = "brightness(1.0)")}
335
- >
336
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
337
- <line x1="12" y1="5" x2="12" y2="19" />
338
- <line x1="5" y1="12" x2="19" y2="12" />
339
- </svg>
340
- Install Plugin
268
+ <button className="btn btn-primary" onClick={() => { setInstallOpen(true); setAlert(null) }}>
269
+ Install from source…
341
270
  </button>
342
271
  </div>
343
272
 
344
- {/* Alert Banner */}
345
273
  {alert && (
346
- <div style={{
347
- padding: "12px 18px",
348
- borderRadius: "var(--radius-md)",
349
- marginBottom: "24px",
350
- fontSize: "14px",
351
- display: "flex",
352
- justifyContent: "space-between",
353
- alignItems: "center",
354
- background: alert.type === "success" ? "var(--success-muted)" : "var(--error-muted)",
355
- border: `1px solid ${alert.type === "success" ? "var(--success)" : "var(--error)"}`,
356
- color: alert.type === "success" ? "var(--success)" : "var(--error)"
357
- }}>
274
+ <div className={`alert ${alert.type === "success" ? "alert-success" : "alert-error"}`} role="status"
275
+ style={{ display: "flex", justifyContent: "space-between", gap: "12px", marginBottom: "20px" }}>
358
276
  <span>{alert.message}</span>
359
- <button
360
- onClick={() => setAlert(null)}
361
- style={{ background: "none", border: "none", color: "inherit", cursor: "pointer", fontSize: "16px" }}
362
- >
363
- ×
364
- </button>
277
+ <button onClick={() => setAlert(null)} aria-label="Dismiss" style={{ background: "none", border: "none", color: "inherit", cursor: "pointer" }}>×</button>
365
278
  </div>
366
279
  )}
367
280
 
368
- {/* Tabs, Search & Sort Bar */}
369
- <div style={{
370
- display: "flex",
371
- justifyContent: "space-between",
372
- alignItems: "center",
373
- borderBottom: "1px solid var(--border)",
374
- paddingBottom: "16px",
375
- marginBottom: "24px",
376
- gap: "16px",
377
- flexWrap: "wrap"
378
- }}>
379
- <div style={{ display: "flex", gap: "8px" }}>
380
- <button
381
- onClick={() => setActiveTab("installed")}
382
- style={{
383
- background: activeTab === "installed" ? "var(--bg-elevated)" : "transparent",
384
- color: activeTab === "installed" ? "var(--text-primary)" : "var(--text-secondary)",
385
- border: activeTab === "installed" ? "1px solid var(--border-accent)" : "1px solid transparent",
386
- borderRadius: "var(--radius-sm)",
387
- padding: "8px 16px",
388
- fontSize: "14px",
389
- fontWeight: "600",
390
- cursor: "pointer",
391
- display: "flex",
392
- alignItems: "center",
393
- gap: "8px"
394
- }}
395
- >
396
- <span>Installed</span>
397
- <span style={{
398
- background: "var(--bg-tertiary)",
399
- padding: "2px 7px",
400
- borderRadius: "10px",
401
- fontSize: "12px",
402
- color: "var(--text-tertiary)"
403
- }}>
404
- {plugins.length}
405
- </span>
406
- </button>
407
-
408
- <button
409
- onClick={() => setActiveTab("catalog")}
410
- style={{
411
- background: activeTab === "catalog" ? "var(--bg-elevated)" : "transparent",
412
- color: activeTab === "catalog" ? "var(--text-primary)" : "var(--text-secondary)",
413
- border: activeTab === "catalog" ? "1px solid var(--border-accent)" : "1px solid transparent",
414
- borderRadius: "var(--radius-sm)",
415
- padding: "8px 16px",
416
- fontSize: "14px",
417
- fontWeight: "600",
418
- cursor: "pointer",
419
- display: "flex",
420
- alignItems: "center",
421
- gap: "8px"
422
- }}
423
- >
424
- <span>Discover Catalog</span>
425
- <span style={{
426
- background: "var(--accent-muted)",
427
- color: "var(--accent-hover)",
428
- padding: "2px 7px",
429
- borderRadius: "10px",
430
- fontSize: "12px"
431
- }}>
432
- {catalog.length}
433
- </span>
434
- </button>
281
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "12px", flexWrap: "wrap", marginBottom: "20px" }}>
282
+ <div role="tablist" style={{ display: "flex", gap: "8px" }}>
283
+ {tabButton("installed", "Installed", installed.length)}
284
+ {tabButton("bundled", "Bundled", bundled.length)}
285
+ {tabButton("mesh", "On the mesh")}
435
286
  </div>
436
-
437
- <div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
438
- <div style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)" }}>
439
- <span>Sort:</span>
440
- <select
441
- value={sortBy}
442
- onChange={(e) => setSortBy(e.target.value as any)}
443
- style={{
444
- background: "var(--bg-secondary)",
445
- border: "1px solid var(--border)",
446
- borderRadius: "var(--radius-sm)",
447
- color: "var(--text-primary)",
448
- padding: "6px 10px",
449
- fontSize: "13px",
450
- outline: "none",
451
- cursor: "pointer"
452
- }}
453
- >
454
- <option value="rating">Highest Rated</option>
455
- <option value="reviews">Most Reviews</option>
456
- <option value="name">Alphabetical</option>
457
- </select>
458
- </div>
459
-
460
- <div style={{ position: "relative", minWidth: "260px" }}>
461
- <input
462
- type="text"
463
- placeholder={activeTab === "installed" ? "Filter installed..." : "Search catalog..."}
464
- value={searchQuery}
465
- onChange={(e) => setSearchQuery(e.target.value)}
466
- style={{
467
- width: "100%",
468
- background: "var(--bg-secondary)",
469
- border: "1px solid var(--border)",
470
- borderRadius: "var(--radius-md)",
471
- padding: "8px 14px 8px 36px",
472
- color: "var(--text-primary)",
473
- fontSize: "13px",
474
- outline: "none"
475
- }}
476
- />
477
- <svg
478
- width="15"
479
- height="15"
480
- viewBox="0 0 24 24"
481
- fill="none"
482
- stroke="var(--text-tertiary)"
483
- strokeWidth="2"
484
- style={{ position: "absolute", left: "12px", top: "50%", transform: "translateY(-50%)" }}
485
- >
486
- <circle cx="11" cy="11" r="8" />
487
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
488
- </svg>
489
- </div>
287
+ <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
288
+ <input className="input" placeholder="Search plugins" value={query} onChange={(e) => setQuery(e.target.value)}
289
+ aria-label="Search plugins" style={{ width: "220px" }} />
290
+ <select className="select" value={useCase} onChange={(e) => setUseCase(e.target.value)} aria-label="Filter by use case" style={{ paddingRight: "32px" }}>
291
+ <option value="">All use cases</option>
292
+ {allUseCases.map((u) => <option key={u} value={u}>{u}</option>)}
293
+ </select>
490
294
  </div>
491
295
  </div>
492
296
 
493
- {/* Tab Content */}
494
- {loading ? (
495
- <div style={{ padding: "60px 0", textAlign: "center", color: "var(--text-tertiary)" }}>
496
- Loading plugins...
497
- </div>
498
- ) : activeTab === "installed" ? (
297
+ {tab === "installed" && (
298
+ loading ? <div style={muted}>Loading…</div> :
499
299
  filteredInstalled.length === 0 ? (
500
- <div style={{
501
- padding: "60px 20px",
502
- textAlign: "center",
503
- background: "var(--bg-secondary)",
504
- borderRadius: "var(--radius-lg)",
505
- border: "1px dashed var(--border)"
506
- }}>
507
- <div style={{ fontSize: "36px", marginBottom: "12px" }}>🧩</div>
508
- <h3 style={{ margin: "0 0 8px 0", fontSize: "18px" }}>No Plugins Found</h3>
509
- <p style={{ margin: "0 0 20px 0", color: "var(--text-secondary)", fontSize: "14px" }}>
510
- {searchQuery ? "No installed plugins match your search filter." : "You do not have any plugins installed in this brain."}
511
- </p>
512
- <button
513
- onClick={() => setActiveTab("catalog")}
514
- style={{
515
- background: "var(--bg-elevated)",
516
- border: "1px solid var(--border-accent)",
517
- color: "var(--accent-hover)",
518
- padding: "8px 18px",
519
- borderRadius: "var(--radius-sm)",
520
- cursor: "pointer",
521
- fontWeight: "600",
522
- fontSize: "14px"
523
- }}
524
- >
525
- Browse Discovery Catalog
526
- </button>
527
- </div>
300
+ <Empty title={installed.length ? "No installed plugins match" : "No plugins installed"}>
301
+ {!installed.length && <>Start with a <button className="btn btn-ghost btn-sm" onClick={() => setTab("bundled")}>bundled plugin</button> or see what your peers share.</>}
302
+ </Empty>
528
303
  ) : (
529
- <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(360px, 1fr))", gap: "20px" }}>
304
+ <div className="card-grid">
530
305
  {filteredInstalled.map((p) => (
531
- <div
532
- key={p.id}
533
- style={{
534
- background: "var(--bg-secondary)",
535
- border: "1px solid var(--border)",
536
- borderRadius: "var(--radius-lg)",
537
- padding: "24px",
538
- display: "flex",
539
- flexDirection: "column",
540
- justifyContent: "space-between",
541
- transition: "all 0.2s ease"
542
- }}
543
- >
544
- <div>
545
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "10px" }}>
546
- <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
547
- <div style={{
548
- width: "38px",
549
- height: "38px",
550
- borderRadius: "8px",
551
- background: "var(--accent-muted)",
552
- color: "var(--accent-hover)",
553
- display: "flex",
554
- alignItems: "center",
555
- justifyContent: "center",
556
- fontSize: "20px"
557
- }}>
558
- 🧩
559
- </div>
560
- <div>
561
- <h3 style={{ margin: 0, fontSize: "16px", fontWeight: "700" }}>{p.name}</h3>
562
- <span style={{ fontSize: "12px", color: "var(--text-tertiary)", fontFamily: "var(--font-mono)" }}>
563
- {p.id}
564
- </span>
565
- </div>
566
- </div>
567
-
568
- <span style={{
569
- fontSize: "12px",
570
- fontWeight: "600",
571
- padding: "2px 8px",
572
- borderRadius: "6px",
573
- background: p.valid ? "var(--success-muted)" : "var(--error-muted)",
574
- color: p.valid ? "var(--success)" : "var(--error)",
575
- border: `1px solid ${p.valid ? "rgba(52, 211, 153, 0.2)" : "rgba(248, 113, 113, 0.2)"}`
576
- }}>
577
- v{p.version}
578
- </span>
579
- </div>
580
-
581
- {/* Rating & Installs */}
582
- <div style={{ marginBottom: "12px" }}>
583
- <StarRatingDisplay
584
- rating={p.rating || 4.9}
585
- reviewCount={p.reviewCount || 1}
586
- installCount={p.installCount || "1k"}
587
- />
588
- </div>
589
-
590
- <p style={{ fontSize: "13px", color: "var(--text-secondary)", lineHeight: "1.5", margin: "0 0 16px 0" }}>
591
- {p.description}
592
- </p>
593
-
594
- {/* Badges / Features */}
595
- <div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "16px" }}>
596
- {p.cli?.command && (
597
- <span style={{
598
- background: "var(--bg-elevated)",
599
- border: "1px solid var(--border)",
600
- color: "var(--text-primary)",
601
- fontFamily: "var(--font-mono)",
602
- fontSize: "11px",
603
- padding: "3px 8px",
604
- borderRadius: "4px"
605
- }}>
606
- cli: total-recall {p.cli.command}
607
- </span>
608
- )}
609
- {p.categories?.length > 0 && (
610
- <span style={{
611
- background: "var(--accent-muted)",
612
- color: "var(--accent-hover)",
613
- fontSize: "11px",
614
- padding: "3px 8px",
615
- borderRadius: "4px"
616
- }}>
617
- {p.categories.length} SSSS schemas
618
- </span>
619
- )}
620
- {p.tasks?.length > 0 && (
621
- <span style={{
622
- background: "var(--bg-elevated)",
623
- color: "var(--text-secondary)",
624
- fontSize: "11px",
625
- padding: "3px 8px",
626
- borderRadius: "4px"
627
- }}>
628
- {p.tasks.length} tasks
629
- </span>
630
- )}
631
- </div>
306
+ <div key={`${p.scope}:${p.id}`} className="card" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
307
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "baseline" }}>
308
+ <button onClick={() => openDetail(p)} style={{ background: "none", border: "none", padding: 0, color: "var(--text-primary)", fontWeight: 600, fontSize: "15px", cursor: "pointer", textAlign: "left" }}>
309
+ {p.name}
310
+ </button>
311
+ <span style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</span>
632
312
  </div>
633
-
634
- <div>
635
- {/* User Rating Widget */}
636
- <div style={{
637
- background: "var(--bg-primary)",
638
- borderRadius: "var(--radius-sm)",
639
- padding: "8px 12px",
640
- marginBottom: "14px",
641
- display: "flex",
642
- justifyContent: "space-between",
643
- alignItems: "center"
644
- }}>
645
- <InteractiveRater
646
- pluginId={p.id}
647
- currentRating={p.userRating}
648
- onRated={(score) => handlePluginRated(p.id, score)}
649
- />
650
- </div>
651
-
652
- <div style={{
653
- display: "flex",
654
- justifyContent: "space-between",
655
- alignItems: "center",
656
- borderTop: "1px solid var(--border)",
657
- paddingTop: "14px"
658
- }}>
659
- <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
660
- {p.cli?.command && (
661
- <button
662
- onClick={() => openDetail(p, "runner")}
663
- style={{
664
- background: "var(--accent-muted)",
665
- border: "1px solid var(--border-accent)",
666
- color: "var(--accent-hover)",
667
- borderRadius: "var(--radius-sm)",
668
- padding: "5px 12px",
669
- fontSize: "12px",
670
- fontWeight: "600",
671
- cursor: "pointer",
672
- display: "flex",
673
- alignItems: "center",
674
- gap: "5px"
675
- }}
676
- >
677
- ▶ Run
678
- </button>
679
- )}
680
- <button
681
- onClick={() => openDetail(p, "readme")}
682
- style={{
683
- background: "none",
684
- border: "1px solid var(--border)",
685
- color: "var(--text-secondary)",
686
- borderRadius: "var(--radius-sm)",
687
- padding: "5px 10px",
688
- fontSize: "12px",
689
- cursor: "pointer"
690
- }}
691
- >
692
- Inspect
693
- </button>
694
- </div>
695
-
696
- <button
697
- onClick={() => handleUninstall(p)}
698
- style={{
699
- background: "none",
700
- border: "none",
701
- color: "var(--error)",
702
- fontSize: "12px",
703
- cursor: "pointer",
704
- padding: "5px 8px",
705
- borderRadius: "var(--radius-sm)"
706
- }}
707
- >
708
- Uninstall
709
- </button>
710
- </div>
313
+ <div style={muted}>{p.description}</div>
314
+ <UseCaseChips useCases={p.use_cases} />
315
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
316
+ <span className="badge">{p.scope}</span>
317
+ <span className="badge">{sourceLabel(p)}</span>
318
+ {p.shared && <span className="badge badge-accent">shared</span>}
319
+ {!p.valid && <span className="badge badge-error">invalid manifest</span>}
320
+ {p.modified_since_install && <span className="badge badge-warning">changed since install</span>}
321
+ </div>
322
+ <div style={{ ...mono, color: "var(--text-tertiary)" }} title={p.sha256 || undefined}>sha256 {shortHash(p.sha256)}</div>
323
+ <div style={{ display: "flex", gap: "8px", marginTop: "auto", flexWrap: "wrap" }}>
324
+ <button className="btn btn-ghost btn-sm" onClick={() => openDetail(p)}>Open</button>
325
+ <button className="btn btn-ghost btn-sm" disabled={busy === p.id || (!p.valid && !p.shared)} onClick={() => doShare(p, !p.shared)}>
326
+ {p.shared ? "Stop sharing" : "Share"}
327
+ </button>
328
+ <button className="btn btn-ghost btn-sm" disabled={busy === p.id} onClick={() => doRemove(p)}>Remove</button>
711
329
  </div>
712
330
  </div>
713
331
  ))}
714
332
  </div>
715
333
  )
716
- ) : (
717
- /* Catalog View */
718
- <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(360px, 1fr))", gap: "20px" }}>
719
- {filteredCatalog.map((item) => (
720
- <div
721
- key={item.id}
722
- style={{
723
- background: "var(--bg-secondary)",
724
- border: "1px solid var(--border)",
725
- borderRadius: "var(--radius-lg)",
726
- padding: "24px",
727
- display: "flex",
728
- flexDirection: "column",
729
- justifyContent: "space-between"
730
- }}
731
- >
732
- <div>
733
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "8px" }}>
734
- <div>
735
- <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
736
- <h3 style={{ margin: 0, fontSize: "16px", fontWeight: "700" }}>{item.name}</h3>
737
- {item.verified && (
738
- <span title="Verified Plugin" style={{ color: "var(--accent)", fontSize: "14px" }}>
739
- ✓
740
- </span>
741
- )}
742
- </div>
743
- <span style={{ fontSize: "12px", color: "var(--text-tertiary)", fontFamily: "var(--font-mono)" }}>
744
- {item.id} • by {item.author}
745
- </span>
746
- </div>
747
- <span style={{ fontSize: "12px", color: "var(--text-secondary)", fontWeight: "600" }}>
748
- v{item.version}
749
- </span>
750
- </div>
334
+ )}
751
335
 
752
- {/* Rating display */}
753
- <div style={{ marginBottom: "12px" }}>
754
- <StarRatingDisplay
755
- rating={item.rating}
756
- reviewCount={item.reviewCount}
757
- installCount={item.installCount}
758
- />
336
+ {tab === "bundled" && (
337
+ loading ? <div style={muted}>Loading…</div> :
338
+ filteredBundled.length === 0 ? <Empty title="No bundled plugins match" /> : (
339
+ <>
340
+ <p style={{ ...muted, marginBottom: "16px" }}>Shipped with this version of Total Recall, so they install on any node.</p>
341
+ <div className="card-grid">
342
+ {filteredBundled.map((p) => (
343
+ <div key={p.id} className="card" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
344
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "8px", alignItems: "baseline" }}>
345
+ <span style={{ fontWeight: 600, fontSize: "15px" }}>{p.name}</span>
346
+ <span style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</span>
347
+ </div>
348
+ <div style={muted}>{p.description}</div>
349
+ <UseCaseChips useCases={p.use_cases} />
350
+ <div style={{ ...muted, fontSize: "12px" }}>
351
+ {[
352
+ p.cli && `command: total-recall ${p.cli.command}`,
353
+ p.tasks.length > 0 && `${p.tasks.length} scheduled task${p.tasks.length > 1 ? "s" : ""}`,
354
+ p.categories.length > 0 && `${p.categories.length} memory categor${p.categories.length > 1 ? "ies" : "y"}`
355
+ ].filter(Boolean).join(" · ")}
356
+ </div>
357
+ <div style={{ marginTop: "auto" }}>
358
+ {p.installed
359
+ ? <span className="badge badge-success">installed</span>
360
+ : <button className="btn btn-primary btn-sm" disabled={busy === p.id} onClick={() => doInstall(p.id)}>
361
+ {busy === p.id ? "Installing…" : "Install"}
362
+ </button>}
363
+ </div>
759
364
  </div>
365
+ ))}
366
+ </div>
367
+ </>
368
+ )
369
+ )}
760
370
 
761
- <p style={{ fontSize: "13px", color: "var(--text-secondary)", lineHeight: "1.5", margin: "0 0 16px 0" }}>
762
- {item.description}
763
- </p>
764
-
765
- <div style={{ display: "flex", flexWrap: "wrap", gap: "6px", marginBottom: "20px" }}>
766
- {item.tags.map((tag) => (
767
- <span key={tag} style={{
768
- background: "var(--bg-elevated)",
769
- color: "var(--text-tertiary)",
770
- fontSize: "11px",
771
- padding: "2px 8px",
772
- borderRadius: "4px"
773
- }}>
774
- #{tag}
775
- </span>
776
- ))}
777
- </div>
778
- </div>
371
+ {tab === "mesh" && (
372
+ <div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
373
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: "12px", flexWrap: "wrap" }}>
374
+ <p style={{ ...muted, margin: 0 }}>Asked live from each node. Only plugins their owner chose to share appear here; content is hash-checked on install.</p>
375
+ <button className="btn btn-ghost btn-sm" onClick={loadPeers} disabled={peersLoading}>{peersLoading ? "Asking peers…" : "Refresh"}</button>
376
+ </div>
779
377
 
780
- <div style={{ borderTop: "1px solid var(--border)", paddingTop: "16px", display: "flex", justifyContent: "flex-end" }}>
781
- {item.isInstalled ? (
782
- <span style={{
783
- color: "var(--success)",
784
- fontSize: "13px",
785
- fontWeight: "600",
786
- display: "flex",
787
- alignItems: "center",
788
- gap: "6px"
789
- }}>
790
- ✓ Installed
791
- </span>
792
- ) : (
793
- <button
794
- disabled={installing}
795
- onClick={() => handleCatalogInstall(item)}
796
- style={{
797
- background: "var(--accent)",
798
- color: "#ffffff",
799
- border: "none",
800
- borderRadius: "var(--radius-sm)",
801
- padding: "7px 16px",
802
- fontSize: "13px",
803
- fontWeight: "600",
804
- cursor: installing ? "not-allowed" : "pointer"
805
- }}
806
- >
807
- {installing ? "Installing..." : "Install"}
808
- </button>
378
+ {peersLoading && !peers ? <div style={muted}>Asking peers…</div> :
379
+ peers === null ? <Empty title="Could not ask the mesh">The brain server did not answer the peer query.</Empty> :
380
+ !peers.mesh.available ? (
381
+ <Empty title="This node is not on a mesh">Enroll it with <code style={mono}>npx total-recall mesh enroll</code> to share plugins between your nodes.</Empty>
382
+ ) : peers.peers.length === 0 ? <Empty title="No other nodes on the mesh" /> : (
383
+ <>
384
+ {!peers.mesh.configured && (
385
+ <div className="alert alert-warning">This node has no <code style={mono}>TR_MESH_SYNC_TOKEN</code>, so it cannot query peers. Set the same token on each node.</div>
809
386
  )}
810
- </div>
811
- </div>
812
- ))}
387
+ {peers.peers.map((node) => {
388
+ const shown = node.plugins.filter(matches)
389
+ return (
390
+ <div key={node.hostname} className="card">
391
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "12px", alignItems: "center", marginBottom: node.plugins.length ? "12px" : 0, flexWrap: "wrap" }}>
392
+ <div>
393
+ <span style={{ fontWeight: 600 }}>{node.hostname}</span>{" "}
394
+ <span style={{ ...mono, color: "var(--text-tertiary)" }}>{node.ip}</span>
395
+ </div>
396
+ <span className={`badge ${node.status === "ok" ? "badge-online" : node.status === "offline" ? "badge-offline" : "badge-warning"}`}>
397
+ {PEER_STATUS_LABEL[node.status]}{node.status === "ok" ? ` · ${node.plugins.length} shared` : ""}
398
+ </span>
399
+ </div>
400
+ {node.error && node.status !== "ok" && <div style={{ ...muted, fontSize: "12px" }}>{node.error}</div>}
401
+ {node.status === "ok" && node.plugins.length === 0 && <div style={muted}>Shares no plugins.</div>}
402
+ {node.status === "ok" && node.plugins.length > 0 && shown.length === 0 && <div style={muted}>No shared plugins match the filter.</div>}
403
+ {shown.map((p) => {
404
+ const source = `peer:${node.hostname}/${p.id}`
405
+ return (
406
+ <div key={p.id} style={{ display: "flex", justifyContent: "space-between", gap: "16px", padding: "12px 0", borderTop: "1px solid var(--border)", flexWrap: "wrap" }}>
407
+ <div style={{ minWidth: 0, flex: "1 1 320px", display: "flex", flexDirection: "column", gap: "6px" }}>
408
+ <div><span style={{ fontWeight: 600 }}>{p.name}</span> <span style={{ ...mono, color: "var(--text-tertiary)" }}>v{p.version}</span></div>
409
+ <div style={muted}>{p.description}</div>
410
+ <UseCaseChips useCases={p.use_cases} />
411
+ <div style={{ ...mono, color: "var(--text-tertiary)" }} title={p.sha256}>
412
+ sha256 {shortHash(p.sha256)} · {p.file_count} files · {formatBytes(p.size_bytes)}
413
+ </div>
414
+ </div>
415
+ <div style={{ alignSelf: "center" }}>
416
+ {p.installed
417
+ ? <span className={`badge ${p.same_as_installed ? "badge-success" : "badge-warning"}`}>
418
+ {p.same_as_installed ? "installed (same content)" : "installed (different content)"}
419
+ </span>
420
+ : <button className="btn btn-primary btn-sm" disabled={busy === source} onClick={() => doInstall(source)}>
421
+ {busy === source ? "Installing…" : "Install"}
422
+ </button>}
423
+ </div>
424
+ </div>
425
+ )
426
+ })}
427
+ </div>
428
+ )
429
+ })}
430
+ </>
431
+ )}
813
432
  </div>
814
433
  )}
815
434
 
816
- {/* Install Plugin Modal */}
817
- {installModalOpen && (
818
- <div style={{
819
- position: "fixed",
820
- inset: 0,
821
- background: "rgba(0, 0, 0, 0.75)",
822
- display: "flex",
823
- alignItems: "center",
824
- justifyContent: "center",
825
- zIndex: 1000,
826
- backdropFilter: "blur(4px)"
827
- }}>
828
- <div style={{
829
- background: "var(--bg-secondary)",
830
- border: "1px solid var(--border-accent)",
831
- borderRadius: "var(--radius-xl)",
832
- padding: "32px",
833
- width: "100%",
834
- maxWidth: "520px",
835
- boxShadow: "0 20px 40px rgba(0,0,0,0.6)"
836
- }}>
837
- <h2 style={{ margin: "0 0 8px 0", fontSize: "20px", fontWeight: "700" }}>Install Plugin</h2>
838
- <p style={{ margin: "0 0 24px 0", color: "var(--text-secondary)", fontSize: "14px" }}>
839
- Install a plugin from a local directory path or Git repository URL.
840
- </p>
841
-
842
- <form onSubmit={handleInstallSubmit}>
843
- <div style={{ marginBottom: "18px" }}>
844
- <label style={{ display: "block", fontSize: "13px", fontWeight: "600", marginBottom: "6px", color: "var(--text-secondary)" }}>
845
- Source (Local Directory or Git URL)
846
- </label>
847
- <input
848
- type="text"
849
- required
850
- placeholder="e.g. ./.agent/plugins/my-plugin or https://github.com/..."
851
- value={installSource}
852
- onChange={(e) => setInstallSource(e.target.value)}
853
- style={{
854
- width: "100%",
855
- background: "var(--bg-primary)",
856
- border: "1px solid var(--border)",
857
- borderRadius: "var(--radius-md)",
858
- padding: "10px 14px",
859
- color: "var(--text-primary)",
860
- fontSize: "14px",
861
- outline: "none"
862
- }}
863
- />
864
- </div>
865
-
866
- <div style={{ marginBottom: "14px" }}>
867
- <label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
868
- <input
869
- type="checkbox"
870
- checked={installLink}
871
- onChange={(e) => setInstallLink(e.target.checked)}
872
- />
873
- <span>Link as Symlink (development mode — live edits sync without reinstalling)</span>
874
- </label>
875
- </div>
876
-
877
- <div style={{ marginBottom: "24px" }}>
878
- <label style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "13px", cursor: "pointer" }}>
879
- <input
880
- type="checkbox"
881
- checked={installGlobal}
882
- onChange={(e) => setInstallGlobal(e.target.checked)}
883
- />
884
- <span>Install Globally (~/.agent/plugins across all projects)</span>
885
- </label>
886
- </div>
887
-
888
- <div style={{ display: "flex", justifyContent: "flex-end", gap: "12px" }}>
889
- <button
890
- type="button"
891
- onClick={() => setInstallModalOpen(false)}
892
- style={{
893
- background: "transparent",
894
- border: "1px solid var(--border)",
895
- color: "var(--text-secondary)",
896
- padding: "8px 16px",
897
- borderRadius: "var(--radius-md)",
898
- fontSize: "14px",
899
- cursor: "pointer"
900
- }}
901
- >
902
- Cancel
903
- </button>
904
- <button
905
- type="submit"
906
- disabled={installing || !installSource.trim()}
907
- style={{
908
- background: "var(--accent)",
909
- border: "none",
910
- color: "#ffffff",
911
- padding: "8px 20px",
912
- borderRadius: "var(--radius-md)",
913
- fontSize: "14px",
914
- fontWeight: "600",
915
- cursor: installing ? "not-allowed" : "pointer"
916
- }}
917
- >
918
- {installing ? "Installing..." : "Install Plugin"}
919
- </button>
920
- </div>
921
- </form>
922
- </div>
435
+ {installOpen && (
436
+ <div role="dialog" aria-modal="true" aria-label="Install plugin from source" onClick={() => setInstallOpen(false)}
437
+ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000, padding: "16px" }}>
438
+ <form className="card" onClick={(e) => e.stopPropagation()} style={{ width: "100%", maxWidth: "520px", display: "flex", flexDirection: "column", gap: "14px" }}
439
+ onSubmit={async (e) => {
440
+ e.preventDefault()
441
+ if (!installSource.trim()) return
442
+ if (await doInstall(installSource.trim(), { link: installLink, global: installGlobal })) {
443
+ setInstallOpen(false)
444
+ setInstallSource("")
445
+ }
446
+ }}>
447
+ <div style={{ fontWeight: 600, fontSize: "16px" }}>Install from source</div>
448
+ <div style={muted}>
449
+ A bundled plugin id, <code style={mono}>peer:&lt;host&gt;/&lt;id&gt;</code>, a git URL, or a folder on this machine containing <code style={mono}>plugin.json</code>.
450
+ </div>
451
+ <input className="input" autoFocus value={installSource} onChange={(e) => setInstallSource(e.target.value)}
452
+ placeholder="https://github.com/you/my-plugin.git" aria-label="Plugin source" />
453
+ <label style={{ ...muted, display: "flex", gap: "8px", alignItems: "center" }}>
454
+ <input type="checkbox" checked={installLink} onChange={(e) => setInstallLink(e.target.checked)} />
455
+ Link a local folder instead of copying it (for plugin development)
456
+ </label>
457
+ <label style={{ ...muted, display: "flex", gap: "8px", alignItems: "center" }}>
458
+ <input type="checkbox" checked={installGlobal} onChange={(e) => setInstallGlobal(e.target.checked)} />
459
+ Install for every project on this machine
460
+ </label>
461
+ <div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
462
+ <button type="button" className="btn btn-ghost" onClick={() => setInstallOpen(false)}>Cancel</button>
463
+ <button type="submit" className="btn btn-primary" disabled={!installSource.trim() || busy !== null}>
464
+ {busy ? "Installing…" : "Install"}
465
+ </button>
466
+ </div>
467
+ </form>
923
468
  </div>
924
469
  )}
925
470
 
926
- {/* Plugin Detail & Interactive Runner Modal */}
927
- {detailModalPlugin && (
928
- <div style={{
929
- position: "fixed",
930
- inset: 0,
931
- background: "rgba(0, 0, 0, 0.75)",
932
- display: "flex",
933
- alignItems: "center",
934
- justifyContent: "center",
935
- zIndex: 1000,
936
- backdropFilter: "blur(6px)",
937
- padding: "20px"
938
- }}>
939
- <div style={{
940
- background: "var(--bg-secondary)",
941
- border: "1px solid var(--border-accent)",
942
- borderRadius: "var(--radius-xl)",
943
- padding: "24px 28px",
944
- width: "100%",
945
- maxWidth: "760px",
946
- maxHeight: "88vh",
947
- display: "flex",
948
- flexDirection: "column",
949
- boxShadow: "0 20px 40px rgba(0, 0, 0, 0.5)"
950
- }}>
951
- {/* Modal Header */}
952
- <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "18px" }}>
953
- <div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
954
- <div style={{
955
- width: "42px",
956
- height: "42px",
957
- borderRadius: "10px",
958
- background: "var(--accent-muted)",
959
- color: "var(--accent-hover)",
960
- display: "flex",
961
- alignItems: "center",
962
- justifyContent: "center",
963
- fontSize: "22px"
964
- }}>
965
- 🧩
966
- </div>
967
- <div>
968
- <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
969
- <h2 style={{ margin: 0, fontSize: "18px", fontWeight: "700" }}>{detailModalPlugin.name}</h2>
970
- <span style={{
971
- fontSize: "11px",
972
- fontWeight: "600",
973
- padding: "2px 7px",
974
- borderRadius: "6px",
975
- background: detailModalPlugin.valid ? "var(--success-muted)" : "var(--error-muted)",
976
- color: detailModalPlugin.valid ? "var(--success)" : "var(--error)",
977
- border: `1px solid ${detailModalPlugin.valid ? "rgba(52, 211, 153, 0.2)" : "rgba(248, 113, 113, 0.2)"}`
978
- }}>
979
- v{detailModalPlugin.version}
980
- </span>
981
- </div>
982
- <span style={{ fontSize: "12px", color: "var(--text-tertiary)", fontFamily: "var(--font-mono)" }}>
983
- id: {detailModalPlugin.id}
984
- </span>
985
- </div>
471
+ {detail && (
472
+ <div role="dialog" aria-modal="true" aria-label={detail.name} onClick={closeDetail}
473
+ style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)", display: "flex", justifyContent: "flex-end", zIndex: 1000 }}>
474
+ <div onClick={(e) => e.stopPropagation()}
475
+ style={{ width: "100%", maxWidth: "640px", height: "100%", overflowY: "auto", background: "var(--bg-secondary)", borderLeft: "1px solid var(--border)", padding: "24px", display: "flex", flexDirection: "column", gap: "16px" }}>
476
+ <div style={{ display: "flex", justifyContent: "space-between", gap: "12px" }}>
477
+ <div>
478
+ <div style={{ fontSize: "18px", fontWeight: 700 }}>{detail.name}</div>
479
+ <div style={{ ...mono, color: "var(--text-tertiary)" }}>{detail.id} · v{detail.version}</div>
986
480
  </div>
987
-
988
- <button
989
- onClick={closeDetail}
990
- style={{
991
- background: "none",
992
- border: "none",
993
- color: "var(--text-secondary)",
994
- fontSize: "24px",
995
- cursor: "pointer",
996
- lineHeight: 1
997
- }}
998
- >
999
- ×
1000
- </button>
481
+ <button className="btn btn-ghost btn-sm" onClick={closeDetail} aria-label="Close">Close</button>
1001
482
  </div>
1002
-
1003
- {/* Sub-Navigation Tabs */}
1004
- <div style={{
1005
- display: "flex",
1006
- gap: "6px",
1007
- borderBottom: "1px solid var(--border)",
1008
- paddingBottom: "12px",
1009
- marginBottom: "18px",
1010
- overflowX: "auto"
1011
- }}>
1012
- <button
1013
- onClick={() => setDetailTab("runner")}
1014
- style={{
1015
- background: detailTab === "runner" ? "var(--bg-elevated)" : "transparent",
1016
- color: detailTab === "runner" ? "var(--accent-hover)" : "var(--text-secondary)",
1017
- border: detailTab === "runner" ? "1px solid var(--border-accent)" : "1px solid transparent",
1018
- borderRadius: "var(--radius-sm)",
1019
- padding: "6px 12px",
1020
- fontSize: "13px",
1021
- fontWeight: "600",
1022
- cursor: "pointer",
1023
- display: "flex",
1024
- alignItems: "center",
1025
- gap: "6px"
1026
- }}
1027
- >
1028
- <span>⚡ Interactive Runner</span>
1029
- </button>
1030
-
1031
- <button
1032
- onClick={() => setDetailTab("readme")}
1033
- style={{
1034
- background: detailTab === "readme" ? "var(--bg-elevated)" : "transparent",
1035
- color: detailTab === "readme" ? "var(--accent-hover)" : "var(--text-secondary)",
1036
- border: detailTab === "readme" ? "1px solid var(--border-accent)" : "1px solid transparent",
1037
- borderRadius: "var(--radius-sm)",
1038
- padding: "6px 12px",
1039
- fontSize: "13px",
1040
- fontWeight: "600",
1041
- cursor: "pointer"
1042
- }}
1043
- >
1044
- 📖 Documentation
1045
- </button>
1046
-
1047
- <button
1048
- onClick={() => setDetailTab("schemas")}
1049
- style={{
1050
- background: detailTab === "schemas" ? "var(--bg-elevated)" : "transparent",
1051
- color: detailTab === "schemas" ? "var(--accent-hover)" : "var(--text-secondary)",
1052
- border: detailTab === "schemas" ? "1px solid var(--border-accent)" : "1px solid transparent",
1053
- borderRadius: "var(--radius-sm)",
1054
- padding: "6px 12px",
1055
- fontSize: "13px",
1056
- fontWeight: "600",
1057
- cursor: "pointer"
1058
- }}
1059
- >
1060
- 🧩 SSSS Schemas ({detailModalPlugin.categories?.length || 0})
1061
- </button>
1062
-
1063
- <button
1064
- onClick={() => setDetailTab("tasks")}
1065
- style={{
1066
- background: detailTab === "tasks" ? "var(--bg-elevated)" : "transparent",
1067
- color: detailTab === "tasks" ? "var(--accent-hover)" : "var(--text-secondary)",
1068
- border: detailTab === "tasks" ? "1px solid var(--border-accent)" : "1px solid transparent",
1069
- borderRadius: "var(--radius-sm)",
1070
- padding: "6px 12px",
1071
- fontSize: "13px",
1072
- fontWeight: "600",
1073
- cursor: "pointer"
1074
- }}
1075
- >
1076
- ⏱️ Tasks ({detailModalPlugin.tasks?.length || 0})
1077
- </button>
1078
-
1079
- <button
1080
- onClick={() => setDetailTab("manifest")}
1081
- style={{
1082
- background: detailTab === "manifest" ? "var(--bg-elevated)" : "transparent",
1083
- color: detailTab === "manifest" ? "var(--accent-hover)" : "var(--text-secondary)",
1084
- border: detailTab === "manifest" ? "1px solid var(--border-accent)" : "1px solid transparent",
1085
- borderRadius: "var(--radius-sm)",
1086
- padding: "6px 12px",
1087
- fontSize: "13px",
1088
- fontWeight: "600",
1089
- cursor: "pointer"
1090
- }}
1091
- >
1092
- 📋 Manifest
1093
- </button>
483
+ <div style={muted}>{detail.description}</div>
484
+ {!detail.valid && (
485
+ <div className="alert alert-error">
486
+ {detail.errors.map((e) => <div key={e}>{e}</div>)}
487
+ </div>
488
+ )}
489
+ <div role="tablist" style={{ display: "flex", gap: "8px" }}>
490
+ {detail.cli && <button role="tab" aria-selected={detailTab === "run"} className={`btn btn-sm ${detailTab === "run" ? "btn-primary" : "btn-ghost"}`} onClick={() => setDetailTab("run")}>Run</button>}
491
+ <button role="tab" aria-selected={detailTab === "details"} className={`btn btn-sm ${detailTab === "details" ? "btn-primary" : "btn-ghost"}`} onClick={() => setDetailTab("details")}>Details</button>
492
+ <button role="tab" aria-selected={detailTab === "readme"} className={`btn btn-sm ${detailTab === "readme" ? "btn-primary" : "btn-ghost"}`} onClick={() => setDetailTab("readme")}>README</button>
1094
493
  </div>
1095
494
 
1096
- {/* Tab Body */}
1097
- <div style={{ flex: 1, overflowY: "auto", minHeight: "320px" }}>
1098
- {detailTab === "runner" && (
1099
- <div>
1100
- <div style={{ display: "flex", gap: "10px", alignItems: "center", marginBottom: "14px", flexWrap: "wrap" }}>
1101
- <span style={{ fontSize: "13px", fontWeight: "600", color: "var(--text-secondary)" }}>
1102
- Command: <code style={{ color: "var(--accent-hover)", background: "var(--bg-primary)", padding: "2px 6px", borderRadius: "4px" }}>total-recall {detailModalPlugin.cli?.command || detailModalPlugin.id}</code>
1103
- </span>
1104
-
1105
- <input
1106
- type="text"
1107
- placeholder="subcommand (e.g. status, audit, sample)"
1108
- value={subcommand}
1109
- onChange={(e) => setSubcommand(e.target.value)}
1110
- style={{
1111
- background: "var(--bg-primary)",
1112
- border: "1px solid var(--border)",
1113
- borderRadius: "var(--radius-sm)",
1114
- color: "var(--text-primary)",
1115
- padding: "6px 10px",
1116
- fontSize: "13px",
1117
- width: "160px"
1118
- }}
1119
- />
1120
-
1121
- <input
1122
- type="text"
1123
- placeholder="extra args (e.g. --json)"
1124
- value={customArgs}
1125
- onChange={(e) => setCustomArgs(e.target.value)}
1126
- style={{
1127
- background: "var(--bg-primary)",
1128
- border: "1px solid var(--border)",
1129
- borderRadius: "var(--radius-sm)",
1130
- color: "var(--text-primary)",
1131
- padding: "6px 10px",
1132
- fontSize: "13px",
1133
- flex: 1,
1134
- minWidth: "140px"
1135
- }}
1136
- />
1137
-
1138
- <button
1139
- onClick={handleExecute}
1140
- disabled={executing}
1141
- style={{
1142
- background: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
1143
- color: "#ffffff",
1144
- border: "none",
1145
- borderRadius: "var(--radius-sm)",
1146
- padding: "7px 18px",
1147
- fontSize: "13px",
1148
- fontWeight: "600",
1149
- cursor: executing ? "wait" : "pointer",
1150
- display: "flex",
1151
- alignItems: "center",
1152
- gap: "6px"
1153
- }}
1154
- >
1155
- {executing ? "Running..." : "▶ Execute"}
1156
- </button>
1157
- </div>
1158
-
1159
- {/* Terminal Console View */}
1160
- <div style={{
1161
- background: "#090d16",
1162
- border: "1px solid var(--border)",
1163
- borderRadius: "var(--radius-md)",
1164
- padding: "16px 20px",
1165
- minHeight: "260px",
1166
- maxHeight: "360px",
1167
- overflowY: "auto",
1168
- fontFamily: "var(--font-mono)",
1169
- fontSize: "12px",
1170
- lineHeight: "1.6",
1171
- color: "#e2e8f0",
1172
- position: "relative"
1173
- }}>
1174
- {consoleOutput ? (
1175
- <pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
1176
- {consoleOutput}
1177
- </pre>
1178
- ) : (
1179
- <div style={{ color: "var(--text-tertiary)", fontStyle: "italic", paddingTop: "50px", textAlign: "center" }}>
1180
- Select a subcommand and click "Execute" to run the plugin CLI and capture live output.
1181
- </div>
1182
- )}
1183
-
1184
- {consoleOutput && (
1185
- <div style={{ position: "absolute", top: "10px", right: "12px", display: "flex", gap: "6px" }}>
1186
- <button
1187
- onClick={() => navigator.clipboard.writeText(consoleOutput)}
1188
- style={{
1189
- background: "rgba(255, 255, 255, 0.08)",
1190
- border: "none",
1191
- color: "var(--text-secondary)",
1192
- borderRadius: "4px",
1193
- padding: "3px 8px",
1194
- fontSize: "11px",
1195
- cursor: "pointer"
1196
- }}
1197
- >
1198
- Copy
1199
- </button>
1200
- <button
1201
- onClick={() => setConsoleOutput("")}
1202
- style={{
1203
- background: "rgba(255, 255, 255, 0.08)",
1204
- border: "none",
1205
- color: "var(--text-secondary)",
1206
- borderRadius: "4px",
1207
- padding: "3px 8px",
1208
- fontSize: "11px",
1209
- cursor: "pointer"
1210
- }}
1211
- >
1212
- Clear
1213
- </button>
1214
- </div>
1215
- )}
1216
- </div>
495
+ {detailTab === "run" && detail.cli && (
496
+ <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
497
+ <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
498
+ <select className="select" value={subcommand} onChange={(e) => setSubcommand(e.target.value)} aria-label="Subcommand" style={{ paddingRight: "32px" }}>
499
+ {(detail.cli.subcommands.length ? detail.cli.subcommands : [{ name: "" }]).map((s) => (
500
+ <option key={s.name} value={s.name}>{s.name || "(default)"}</option>
501
+ ))}
502
+ </select>
503
+ <input className="input" value={args} onChange={(e) => setArgs(e.target.value)} placeholder="extra arguments" aria-label="Arguments" style={{ flex: 1, minWidth: "160px" }} />
504
+ <button className="btn btn-primary" onClick={runCommand} disabled={busy === `run:${detail.id}` || !detail.valid}>
505
+ {busy === `run:${detail.id}` ? "Running…" : "Run"}
506
+ </button>
1217
507
  </div>
1218
- )}
1219
-
1220
- {detailTab === "readme" && (
1221
- <div style={{
1222
- background: "var(--bg-primary)",
1223
- border: "1px solid var(--border)",
1224
- borderRadius: "var(--radius-md)",
1225
- padding: "20px 24px",
1226
- maxHeight: "420px",
1227
- overflowY: "auto",
1228
- lineHeight: "1.6"
1229
- }}>
1230
- {readmeLoading ? (
1231
- <div style={{ color: "var(--text-tertiary)", textAlign: "center", padding: "40px" }}>Loading documentation...</div>
1232
- ) : (
1233
- <pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: "inherit", fontSize: "14px", color: "var(--text-primary)" }}>
1234
- {readmeText || "No README.md documentation found for this plugin."}
1235
- </pre>
1236
- )}
508
+ <div style={{ ...muted, fontSize: "12px" }}>
509
+ {detail.cli.subcommands.find((s) => s.name === subcommand)?.description}
510
+ {" "}Same as <code style={mono}>npx total-recall {detail.cli.command} {subcommand} {args}</code>. Runs in a separate process.
1237
511
  </div>
1238
- )}
1239
-
1240
- {detailTab === "schemas" && (
1241
- <div style={{ maxHeight: "420px", overflowY: "auto" }}>
1242
- {detailModalPlugin.categories && detailModalPlugin.categories.length > 0 ? (
1243
- <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
1244
- {detailModalPlugin.categories.map((cat) => (
1245
- <div key={cat.name} style={{
1246
- background: "var(--bg-primary)",
1247
- border: "1px solid var(--border)",
1248
- borderRadius: "var(--radius-md)",
1249
- padding: "14px 18px"
1250
- }}>
1251
- <div style={{ display: "flex", alignItems: "center", gap: "8px", marginBottom: "4px" }}>
1252
- <span style={{ fontWeight: "700", color: "var(--accent-hover)", fontFamily: "var(--font-mono)" }}>
1253
- {cat.name}
1254
- </span>
1255
- <span style={{ fontSize: "11px", background: "var(--bg-elevated)", padding: "2px 6px", borderRadius: "4px", color: "var(--text-tertiary)" }}>
1256
- type: memory
1257
- </span>
1258
- </div>
1259
- <p style={{ margin: 0, fontSize: "13px", color: "var(--text-secondary)" }}>
1260
- {cat.description || "Plugin declared SSSS category schema."}
1261
- </p>
1262
- </div>
1263
- ))}
1264
- </div>
1265
- ) : (
1266
- <div style={{ padding: "40px", textAlign: "center", color: "var(--text-tertiary)" }}>
1267
- This plugin does not declare custom SSSS category schemas.
1268
- </div>
1269
- )}
1270
- </div>
1271
- )}
512
+ {output && (
513
+ <pre style={{ ...mono, margin: 0, padding: "14px", borderRadius: "var(--radius-sm)", background: "var(--bg-primary)", border: `1px solid ${output.ok ? "var(--border)" : "var(--error)"}`, whiteSpace: "pre-wrap", maxHeight: "420px", overflow: "auto" }}>
514
+ {output.text}
515
+ </pre>
516
+ )}
517
+ </div>
518
+ )}
1272
519
 
1273
- {detailTab === "tasks" && (
1274
- <div style={{ maxHeight: "420px", overflowY: "auto" }}>
1275
- {detailModalPlugin.tasks && detailModalPlugin.tasks.length > 0 ? (
1276
- <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
1277
- {detailModalPlugin.tasks.map((t, idx) => (
1278
- <div key={idx} style={{
1279
- background: "var(--bg-primary)",
1280
- border: "1px solid var(--border)",
1281
- borderRadius: "var(--radius-md)",
1282
- padding: "14px 18px",
1283
- display: "flex",
1284
- justifyContent: "space-between",
1285
- alignItems: "center"
1286
- }}>
1287
- <div>
1288
- <div style={{ fontWeight: "600", fontSize: "14px", marginBottom: "4px" }}>
1289
- {t.intent}
1290
- </div>
1291
- <span style={{ fontSize: "12px", color: "var(--text-tertiary)", fontFamily: "var(--font-mono)" }}>
1292
- cron: {t.schedule}
1293
- </span>
1294
- </div>
1295
- <span style={{ fontSize: "12px", background: "var(--success-muted)", color: "var(--success)", padding: "3px 8px", borderRadius: "6px", fontWeight: "600" }}>
1296
- Scheduled
1297
- </span>
520
+ {detailTab === "details" && (
521
+ <div>
522
+ <Fact label="Scope">{detail.scope}{detail.linked ? " (linked folder)" : ""}</Fact>
523
+ <Fact label="Source">{sourceLabel(detail)} — <span style={mono}>{detail.source.ref}</span></Fact>
524
+ <Fact label="Installed">{detail.installed_at ? new Date(detail.installed_at).toLocaleString() : "no install record"}</Fact>
525
+ <Fact label="Content sha256"><span style={mono}>{detail.sha256 || "—"}</span></Fact>
526
+ {detail.modified_since_install && <Fact label="At install"><span style={mono}>{detail.installed_sha256}</span></Fact>}
527
+ <Fact label="Files">{detail.file_count ?? "—"} · {formatBytes(detail.size_bytes)}</Fact>
528
+ <Fact label="Shared">{detail.shared ? "yes" : "no"}</Fact>
529
+ {detail.shared && <Fact label="Public link">{detail.share_url ? <input className="input" aria-label="Public plugin share link" readOnly value={detail.share_url} onFocus={(e) => e.currentTarget.select()} /> : "Set TR_PUBLIC_BASE_URL to your public HTTPS origin to create a link."}</Fact>}
530
+ <Fact label="Use cases">{detail.use_cases.join(", ") || "—"}</Fact>
531
+ {detail.author && <Fact label="Author">{detail.author}</Fact>}
532
+ {detail.license && <Fact label="License">{detail.license}</Fact>}
533
+ <Fact label="Location"><span style={mono}>{detail.dir}</span></Fact>
534
+ <Fact label="Agent context">{detail.has_generator ? "adds a block to compiled instructions" : "—"}</Fact>
535
+ <Fact label="Memory categories">
536
+ {detail.categories.length ? detail.categories.map((c) => c.name).join(", ") : "—"}
537
+ </Fact>
538
+ <Fact label="Scheduled tasks">
539
+ {detail.tasks.length === 0 ? "—" : (
540
+ <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
541
+ {detail.tasks.map((t) => (
542
+ <div key={t.command}>
543
+ <span style={mono}>{t.schedule}</span> {t.command} — {t.intent}
544
+ <div style={{ color: "var(--text-tertiary)", fontSize: "12px" }}>last run: {t.last_run || "not yet"}</div>
1298
545
  </div>
1299
546
  ))}
1300
547
  </div>
1301
- ) : (
1302
- <div style={{ padding: "40px", textAlign: "center", color: "var(--text-tertiary)" }}>
1303
- No background daemon tasks registered for this plugin.
1304
- </div>
1305
548
  )}
549
+ </Fact>
550
+ <div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
551
+ <button className="btn btn-ghost btn-sm" disabled={busy === detail.id || (!detail.valid && !detail.shared)} onClick={() => doShare(detail, !detail.shared)}>
552
+ {detail.shared ? "Stop sharing" : "Share"}
553
+ </button>
554
+ <button className="btn btn-ghost btn-sm" disabled={busy === detail.id} onClick={() => doRemove(detail)}>Remove</button>
1306
555
  </div>
1307
- )}
556
+ </div>
557
+ )}
1308
558
 
1309
- {detailTab === "manifest" && (
1310
- <pre style={{
1311
- margin: 0,
1312
- background: "var(--bg-primary)",
1313
- border: "1px solid var(--border)",
1314
- borderRadius: "var(--radius-md)",
1315
- padding: "16px",
1316
- fontFamily: "var(--font-mono)",
1317
- fontSize: "12px",
1318
- color: "var(--accent-hover)",
1319
- maxHeight: "420px",
1320
- overflow: "auto"
1321
- }}>
1322
- {JSON.stringify(detailModalPlugin.manifest || detailModalPlugin, null, 2)}
1323
- </pre>
1324
- )}
1325
- </div>
559
+ {detailTab === "readme" && (
560
+ <div style={{ fontSize: "14px", lineHeight: 1.6 }}>{readme ? renderMarkdown(readme) : <span style={muted}>Loading…</span>}</div>
561
+ )}
1326
562
  </div>
1327
563
  </div>
1328
564
  )}