@neuralis/brain-core 0.3.1 → 0.4.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 (371) hide show
  1. package/README.md +88 -1417
  2. package/app/README.md +4 -4
  3. package/app/cards/FilesystemCardShell.tsx +42 -4
  4. package/app/cards/FilesystemChangeCard.tsx +34 -11
  5. package/app/cards/README.md +6 -5
  6. package/app/cards/diffUtils.ts +99 -1
  7. package/app/filesystem/FilesystemHost.tsx +24 -2
  8. package/app/filesystem/README.md +1 -1
  9. package/app/filesystem/api/README.md +3 -3
  10. package/app/filesystem/api/index.ts +38 -14
  11. package/app/filesystem/api/projectPackages.ts +13 -102
  12. package/app/filesystem/domain/types.ts +30 -10
  13. package/app/filesystem/hooks/README.md +4 -4
  14. package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
  15. package/app/filesystem/hooks/useFilesystemSSE.ts +42 -2
  16. package/app/filesystem/host/README.md +6 -4
  17. package/app/filesystem/host/buildFilesystemHostValue.ts +28 -2
  18. package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
  19. package/app/filesystem/lib/sourceKindForm.ts +210 -27
  20. package/app/filesystem/lib/treeVisualState.tsx +98 -4
  21. package/app/filesystem/store/README.md +1 -0
  22. package/app/filesystem/store/filesystemStore.ts +99 -8
  23. package/app/filesystem/store/gitSlice.ts +5 -0
  24. package/app/filesystem/store/helpers.ts +17 -0
  25. package/app/filesystem/store/listSlice.ts +8 -0
  26. package/app/filesystem/ui/CodeEditor.tsx +18 -3
  27. package/app/filesystem/ui/DiscoverDock.tsx +5 -1
  28. package/app/filesystem/ui/FileEditor.tsx +15 -10
  29. package/app/filesystem/ui/FileTree.tsx +75 -24
  30. package/app/filesystem/ui/FilesystemWidget.tsx +10 -2
  31. package/app/filesystem/ui/FolderPicker.tsx +251 -0
  32. package/app/filesystem/ui/GitControlPanel.tsx +7 -2
  33. package/app/filesystem/ui/PackagesPanel.tsx +52 -374
  34. package/app/filesystem/ui/PendingChangesPanel.tsx +1 -1
  35. package/app/filesystem/ui/PolicyEditor.tsx +109 -24
  36. package/app/filesystem/ui/README.md +30 -23
  37. package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
  38. package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
  39. package/app/filesystem/ui/SourcesPanel.tsx +502 -342
  40. package/app/filesystem/ui/Toolbar.tsx +1 -2
  41. package/app/filesystem/ui/VectorHealthPanel.tsx +2 -2
  42. package/app/shared/filesystem.png +0 -0
  43. package/dist/app/host.js +58 -46
  44. package/dist/scripts/sourceRootPairV1.d.ts +47 -0
  45. package/dist/scripts/sourceRootPairV1.d.ts.map +1 -0
  46. package/dist/scripts/sourceRootPairV1.js +147 -0
  47. package/dist/scripts/sourceRootPairV1.js.map +1 -0
  48. package/dist/scripts/sourceToFolderV1.d.ts +56 -0
  49. package/dist/scripts/sourceToFolderV1.d.ts.map +1 -0
  50. package/dist/scripts/sourceToFolderV1.js +152 -0
  51. package/dist/scripts/sourceToFolderV1.js.map +1 -0
  52. package/dist/scripts/sourceToHostV1.d.ts +56 -0
  53. package/dist/scripts/sourceToHostV1.d.ts.map +1 -0
  54. package/dist/scripts/sourceToHostV1.js +132 -0
  55. package/dist/scripts/sourceToHostV1.js.map +1 -0
  56. package/dist/src/channels/filesystem.d.ts +1 -1
  57. package/dist/src/channels/filesystem.js +1 -1
  58. package/dist/src/connectors/AppSourceConfigStore.d.ts +17 -3
  59. package/dist/src/connectors/AppSourceConfigStore.d.ts.map +1 -1
  60. package/dist/src/connectors/AppSourceConfigStore.js +46 -6
  61. package/dist/src/connectors/AppSourceConfigStore.js.map +1 -1
  62. package/dist/src/connectors/HostConnector.d.ts +201 -0
  63. package/dist/src/connectors/HostConnector.d.ts.map +1 -0
  64. package/dist/src/connectors/HostConnector.js +903 -0
  65. package/dist/src/connectors/HostConnector.js.map +1 -0
  66. package/dist/src/connectors/inProcessTransport.d.ts +131 -0
  67. package/dist/src/connectors/inProcessTransport.d.ts.map +1 -0
  68. package/dist/src/connectors/inProcessTransport.js +447 -0
  69. package/dist/src/connectors/inProcessTransport.js.map +1 -0
  70. package/dist/src/domain/access/callerPermissions.d.ts +29 -0
  71. package/dist/src/domain/access/callerPermissions.d.ts.map +1 -1
  72. package/dist/src/domain/access/callerPermissions.js +85 -5
  73. package/dist/src/domain/access/callerPermissions.js.map +1 -1
  74. package/dist/src/domain/access/nativeLocalFence.d.ts +65 -0
  75. package/dist/src/domain/access/nativeLocalFence.d.ts.map +1 -0
  76. package/dist/src/domain/access/nativeLocalFence.js +171 -0
  77. package/dist/src/domain/access/nativeLocalFence.js.map +1 -0
  78. package/dist/src/domain/access/uriPermission.js +3 -3
  79. package/dist/src/domain/access/uriPermission.js.map +1 -1
  80. package/dist/src/domain/byteRange.d.ts +15 -0
  81. package/dist/src/domain/byteRange.d.ts.map +1 -0
  82. package/dist/src/domain/byteRange.js +30 -0
  83. package/dist/src/domain/byteRange.js.map +1 -0
  84. package/dist/src/domain/defaultPermissions.d.ts.map +1 -1
  85. package/dist/src/domain/defaultPermissions.js +29 -0
  86. package/dist/src/domain/defaultPermissions.js.map +1 -1
  87. package/dist/src/domain/mime.d.ts +11 -19
  88. package/dist/src/domain/mime.d.ts.map +1 -1
  89. package/dist/src/domain/mime.js +13 -102
  90. package/dist/src/domain/mime.js.map +1 -1
  91. package/dist/src/domain/paths.d.ts.map +1 -1
  92. package/dist/src/domain/paths.js +5 -3
  93. package/dist/src/domain/paths.js.map +1 -1
  94. package/dist/src/domain/scopeAccessCtx.d.ts +2 -2
  95. package/dist/src/domain/scopeAccessCtx.js +2 -2
  96. package/dist/src/domain/sourceConfig.d.ts +52 -19
  97. package/dist/src/domain/sourceConfig.d.ts.map +1 -1
  98. package/dist/src/domain/sourceConfig.js +103 -19
  99. package/dist/src/domain/sourceConfig.js.map +1 -1
  100. package/dist/src/domain/sourcePackages.d.ts +8 -8
  101. package/dist/src/domain/sourcePackages.d.ts.map +1 -1
  102. package/dist/src/domain/sourceRootPair.d.ts +88 -0
  103. package/dist/src/domain/sourceRootPair.d.ts.map +1 -0
  104. package/dist/src/domain/sourceRootPair.js +108 -0
  105. package/dist/src/domain/sourceRootPair.js.map +1 -0
  106. package/dist/src/domain/syncTypes.d.ts +8 -1
  107. package/dist/src/domain/syncTypes.d.ts.map +1 -1
  108. package/dist/src/domain/syncTypes.js +1 -0
  109. package/dist/src/domain/syncTypes.js.map +1 -1
  110. package/dist/src/domain/types.d.ts +32 -2
  111. package/dist/src/domain/types.d.ts.map +1 -1
  112. package/dist/src/domain/uri.d.ts.map +1 -1
  113. package/dist/src/domain/uri.js +3 -2
  114. package/dist/src/domain/uri.js.map +1 -1
  115. package/dist/src/embeddings/clients/GeminiEmbedder.d.ts.map +1 -1
  116. package/dist/src/embeddings/clients/GeminiEmbedder.js +2 -5
  117. package/dist/src/embeddings/clients/GeminiEmbedder.js.map +1 -1
  118. package/dist/src/embeddings/clients/OpenAIEmbedder.d.ts.map +1 -1
  119. package/dist/src/embeddings/clients/OpenAIEmbedder.js +2 -3
  120. package/dist/src/embeddings/clients/OpenAIEmbedder.js.map +1 -1
  121. package/dist/src/embeddings/clients/QwenEmbedder.d.ts +2 -0
  122. package/dist/src/embeddings/clients/QwenEmbedder.d.ts.map +1 -1
  123. package/dist/src/embeddings/clients/QwenEmbedder.js +10 -2
  124. package/dist/src/embeddings/clients/QwenEmbedder.js.map +1 -1
  125. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts +2 -0
  126. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts.map +1 -1
  127. package/dist/src/embeddings/clients/VoyageEmbedder.js +8 -1
  128. package/dist/src/embeddings/clients/VoyageEmbedder.js.map +1 -1
  129. package/dist/src/embeddings/factory.d.ts +5 -3
  130. package/dist/src/embeddings/factory.d.ts.map +1 -1
  131. package/dist/src/embeddings/factory.js +7 -3
  132. package/dist/src/embeddings/factory.js.map +1 -1
  133. package/dist/src/embeddings/models.d.ts +1 -1
  134. package/dist/src/embeddings/models.d.ts.map +1 -1
  135. package/dist/src/embeddings/retry.js +1 -1
  136. package/dist/src/embeddings/retry.js.map +1 -1
  137. package/dist/src/embeddings/types.d.ts +6 -0
  138. package/dist/src/embeddings/types.d.ts.map +1 -1
  139. package/dist/src/embeddings/types.js +11 -0
  140. package/dist/src/embeddings/types.js.map +1 -1
  141. package/dist/src/events/pending.d.ts +1 -1
  142. package/dist/src/events/pending.js +1 -1
  143. package/dist/src/git/GitRunner.d.ts +110 -30
  144. package/dist/src/git/GitRunner.d.ts.map +1 -1
  145. package/dist/src/git/GitRunner.js +511 -199
  146. package/dist/src/git/GitRunner.js.map +1 -1
  147. package/dist/src/git/GitWatchRegistry.d.ts +41 -35
  148. package/dist/src/git/GitWatchRegistry.d.ts.map +1 -1
  149. package/dist/src/git/GitWatchRegistry.js +61 -79
  150. package/dist/src/git/GitWatchRegistry.js.map +1 -1
  151. package/dist/src/git/gitPlace.d.ts +61 -0
  152. package/dist/src/git/gitPlace.d.ts.map +1 -0
  153. package/dist/src/git/gitPlace.js +74 -0
  154. package/dist/src/git/gitPlace.js.map +1 -0
  155. package/dist/src/git/sourceGitControl.d.ts +33 -21
  156. package/dist/src/git/sourceGitControl.d.ts.map +1 -1
  157. package/dist/src/git/sourceGitControl.js +211 -95
  158. package/dist/src/git/sourceGitControl.js.map +1 -1
  159. package/dist/src/git/sourceGitStatus.js +1 -1
  160. package/dist/src/git/sourceGitStatus.js.map +1 -1
  161. package/dist/src/git/untrackedStats.js +1 -1
  162. package/dist/src/git/untrackedStats.js.map +1 -1
  163. package/dist/src/index.d.ts +2 -1
  164. package/dist/src/index.d.ts.map +1 -1
  165. package/dist/src/index.js +2 -1
  166. package/dist/src/index.js.map +1 -1
  167. package/dist/src/lifecycle.d.ts +9 -12
  168. package/dist/src/lifecycle.d.ts.map +1 -1
  169. package/dist/src/lifecycle.js +2 -1
  170. package/dist/src/lifecycle.js.map +1 -1
  171. package/dist/src/routes/approve.d.ts +1 -1
  172. package/dist/src/routes/approve.js +1 -1
  173. package/dist/src/routes/events.d.ts +1 -1
  174. package/dist/src/routes/events.js +1 -1
  175. package/dist/src/routes/git.d.ts +7 -7
  176. package/dist/src/routes/git.d.ts.map +1 -1
  177. package/dist/src/routes/git.js +201 -72
  178. package/dist/src/routes/git.js.map +1 -1
  179. package/dist/src/routes/health.d.ts +6 -6
  180. package/dist/src/routes/health.js +12 -12
  181. package/dist/src/routes/helpers/sourceNotConfigured.d.ts +1 -1
  182. package/dist/src/routes/helpers/sourceNotConfigured.js +1 -1
  183. package/dist/src/routes/helpers/uriGuard.d.ts +1 -1
  184. package/dist/src/routes/helpers/uriGuard.d.ts.map +1 -1
  185. package/dist/src/routes/helpers/uriGuard.js +3 -2
  186. package/dist/src/routes/helpers/uriGuard.js.map +1 -1
  187. package/dist/src/routes/helpers.d.ts +11 -4
  188. package/dist/src/routes/helpers.d.ts.map +1 -1
  189. package/dist/src/routes/helpers.js +13 -6
  190. package/dist/src/routes/helpers.js.map +1 -1
  191. package/dist/src/routes/list.d.ts +1 -1
  192. package/dist/src/routes/list.d.ts.map +1 -1
  193. package/dist/src/routes/list.js +3 -4
  194. package/dist/src/routes/list.js.map +1 -1
  195. package/dist/src/routes/pending.d.ts +4 -4
  196. package/dist/src/routes/pending.js +9 -9
  197. package/dist/src/routes/policies.d.ts +10 -8
  198. package/dist/src/routes/policies.d.ts.map +1 -1
  199. package/dist/src/routes/policies.js +31 -25
  200. package/dist/src/routes/policies.js.map +1 -1
  201. package/dist/src/routes/raw.d.ts +1 -1
  202. package/dist/src/routes/raw.d.ts.map +1 -1
  203. package/dist/src/routes/raw.js +66 -12
  204. package/dist/src/routes/raw.js.map +1 -1
  205. package/dist/src/routes/read.d.ts +1 -1
  206. package/dist/src/routes/read.js +1 -1
  207. package/dist/src/routes/search.d.ts +1 -1
  208. package/dist/src/routes/search.js +1 -1
  209. package/dist/src/routes/sources.d.ts +63 -6
  210. package/dist/src/routes/sources.d.ts.map +1 -1
  211. package/dist/src/routes/sources.js +494 -382
  212. package/dist/src/routes/sources.js.map +1 -1
  213. package/dist/src/routes/upload.d.ts +1 -1
  214. package/dist/src/routes/upload.js +1 -1
  215. package/dist/src/routes/write.d.ts +2 -2
  216. package/dist/src/routes/write.js +2 -2
  217. package/dist/src/services/BridgeService.d.ts +21 -35
  218. package/dist/src/services/BridgeService.d.ts.map +1 -1
  219. package/dist/src/services/BridgeService.js +42 -88
  220. package/dist/src/services/BridgeService.js.map +1 -1
  221. package/dist/src/services/FilesystemService.d.ts +4 -3
  222. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  223. package/dist/src/services/FilesystemService.js +112 -34
  224. package/dist/src/services/FilesystemService.js.map +1 -1
  225. package/dist/src/services/FilesystemUiService.d.ts +4 -1
  226. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  227. package/dist/src/services/FilesystemUiService.js +66 -25
  228. package/dist/src/services/FilesystemUiService.js.map +1 -1
  229. package/dist/src/services/SyncScheduler.d.ts +12 -4
  230. package/dist/src/services/SyncScheduler.d.ts.map +1 -1
  231. package/dist/src/services/SyncScheduler.js +37 -8
  232. package/dist/src/services/SyncScheduler.js.map +1 -1
  233. package/dist/src/services/baselineRepair.d.ts +13 -3
  234. package/dist/src/services/baselineRepair.d.ts.map +1 -1
  235. package/dist/src/services/baselineRepair.js +13 -3
  236. package/dist/src/services/baselineRepair.js.map +1 -1
  237. package/dist/src/services/declaredSources.d.ts +132 -43
  238. package/dist/src/services/declaredSources.d.ts.map +1 -1
  239. package/dist/src/services/declaredSources.js +118 -45
  240. package/dist/src/services/declaredSources.js.map +1 -1
  241. package/dist/src/store/dataFormats.d.ts +17 -2
  242. package/dist/src/store/dataFormats.d.ts.map +1 -1
  243. package/dist/src/store/dataFormats.js +18 -3
  244. package/dist/src/store/dataFormats.js.map +1 -1
  245. package/dist/src/sync/DeltaSyncRunner.d.ts.map +1 -1
  246. package/dist/src/sync/DeltaSyncRunner.js +144 -4
  247. package/dist/src/sync/DeltaSyncRunner.js.map +1 -1
  248. package/dist/src/sync/SyncJobQueue.d.ts +7 -2
  249. package/dist/src/sync/SyncJobQueue.d.ts.map +1 -1
  250. package/dist/src/sync/SyncJobQueue.js +16 -10
  251. package/dist/src/sync/SyncJobQueue.js.map +1 -1
  252. package/dist/src/sync/SyncWatchService.d.ts +21 -4
  253. package/dist/src/sync/SyncWatchService.d.ts.map +1 -1
  254. package/dist/src/sync/SyncWatchService.js +115 -10
  255. package/dist/src/sync/SyncWatchService.js.map +1 -1
  256. package/dist/src/sync/categoryInference.d.ts +1 -1
  257. package/dist/src/sync/categoryInference.js +1 -1
  258. package/dist/src/tools/execCtx.d.ts +2 -2
  259. package/dist/src/tools/execCtx.js +2 -2
  260. package/dist/src/tools/fs_read.d.ts.map +1 -1
  261. package/dist/src/tools/fs_read.js +5 -0
  262. package/dist/src/tools/fs_read.js.map +1 -1
  263. package/dist/src/usecases/admitSourceRoot.d.ts +65 -0
  264. package/dist/src/usecases/admitSourceRoot.d.ts.map +1 -0
  265. package/dist/src/usecases/admitSourceRoot.js +85 -0
  266. package/dist/src/usecases/admitSourceRoot.js.map +1 -0
  267. package/dist/src/usecases/browseSourceFolders.d.ts +85 -0
  268. package/dist/src/usecases/browseSourceFolders.d.ts.map +1 -0
  269. package/dist/src/usecases/browseSourceFolders.js +210 -0
  270. package/dist/src/usecases/browseSourceFolders.js.map +1 -0
  271. package/dist/src/usecases/createDeclaredSources.d.ts +13 -2
  272. package/dist/src/usecases/createDeclaredSources.d.ts.map +1 -1
  273. package/dist/src/usecases/createDeclaredSources.js +39 -0
  274. package/dist/src/usecases/createDeclaredSources.js.map +1 -1
  275. package/dist/src/usecases/embeddingBacklog.js +1 -1
  276. package/dist/src/usecases/embeddingBacklog.js.map +1 -1
  277. package/dist/src/usecases/list.d.ts.map +1 -1
  278. package/dist/src/usecases/list.js +83 -26
  279. package/dist/src/usecases/list.js.map +1 -1
  280. package/dist/src/usecases/listAttachableDevices.d.ts +13 -0
  281. package/dist/src/usecases/listAttachableDevices.d.ts.map +1 -0
  282. package/dist/src/usecases/listAttachableDevices.js +10 -0
  283. package/dist/src/usecases/listAttachableDevices.js.map +1 -0
  284. package/dist/src/usecases/listSourcePackages.d.ts.map +1 -1
  285. package/dist/src/usecases/listSourcePackages.js +2 -5
  286. package/dist/src/usecases/listSourcePackages.js.map +1 -1
  287. package/dist/src/usecases/purgeExcludedPoints.d.ts.map +1 -1
  288. package/dist/src/usecases/purgeExcludedPoints.js +3 -2
  289. package/dist/src/usecases/purgeExcludedPoints.js.map +1 -1
  290. package/dist/src/usecases/readFiles.d.ts.map +1 -1
  291. package/dist/src/usecases/readFiles.js +99 -1
  292. package/dist/src/usecases/readFiles.js.map +1 -1
  293. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts +1 -1
  294. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts.map +1 -1
  295. package/dist/src/usecases/reconcileSourcePackageRoots.js +2 -1
  296. package/dist/src/usecases/reconcileSourcePackageRoots.js.map +1 -1
  297. package/dist/src/usecases/revokePrincipal.d.ts +7 -5
  298. package/dist/src/usecases/revokePrincipal.d.ts.map +1 -1
  299. package/dist/src/usecases/revokePrincipal.js +29 -7
  300. package/dist/src/usecases/revokePrincipal.js.map +1 -1
  301. package/dist/src/usecases/scheduleSourceSync.d.ts +7 -0
  302. package/dist/src/usecases/scheduleSourceSync.d.ts.map +1 -1
  303. package/dist/src/usecases/scheduleSourceSync.js +6 -5
  304. package/dist/src/usecases/scheduleSourceSync.js.map +1 -1
  305. package/dist/src/usecases/syncPointWrites.d.ts +2 -0
  306. package/dist/src/usecases/syncPointWrites.d.ts.map +1 -1
  307. package/dist/src/usecases/syncPointWrites.js +5 -1
  308. package/dist/src/usecases/syncPointWrites.js.map +1 -1
  309. package/dist/src/usecases/triggerSourceSync.d.ts +46 -0
  310. package/dist/src/usecases/triggerSourceSync.d.ts.map +1 -0
  311. package/dist/src/usecases/triggerSourceSync.js +75 -0
  312. package/dist/src/usecases/triggerSourceSync.js.map +1 -0
  313. package/dist/src/usecases/uploadBinary.d.ts +2 -1
  314. package/dist/src/usecases/uploadBinary.d.ts.map +1 -1
  315. package/dist/src/usecases/uploadBinary.js +2 -2
  316. package/dist/src/usecases/uploadBinary.js.map +1 -1
  317. package/dist/src/usecases/vector/endpointGuard.d.ts +1 -1
  318. package/dist/src/usecases/vector/endpointGuard.js +1 -1
  319. package/dist/src/usecases/vectorMaintenance.js +1 -1
  320. package/dist/src/usecases/vectorMaintenance.js.map +1 -1
  321. package/dist/src/usecases/write/content.js +1 -1
  322. package/dist/src/vector/qdrantClient.d.ts +1 -1
  323. package/dist/src/vector/qdrantClient.js +1 -1
  324. package/dist/src/wiring.d.ts +3 -0
  325. package/dist/src/wiring.d.ts.map +1 -1
  326. package/dist/src/wiring.js +72 -6
  327. package/dist/src/wiring.js.map +1 -1
  328. package/dist/tools/fs_list.json +1 -1
  329. package/dist/tools/fs_read.json +1 -1
  330. package/dist/tools/fs_search.json +1 -1
  331. package/dist/tools/fs_write.json +1 -1
  332. package/instructions/brain-model.md +1 -1
  333. package/package.json +212 -114
  334. package/rules/brain-rules.md +2 -2
  335. package/skills/brain-start/SKILL.md +1 -1
  336. package/skills/connect-business-data/SKILL.md +3 -3
  337. package/skills/create-package/SKILL.md +26 -26
  338. package/skills/create-package/references/config-and-credentials.md +16 -13
  339. package/skills/create-package/references/connectors-and-uri-policies.md +39 -25
  340. package/skills/create-package/references/manifest-and-discovery.md +12 -12
  341. package/skills/create-package/references/runtime-and-build.md +19 -19
  342. package/skills/create-package/references/skills-and-context.md +36 -20
  343. package/skills/create-package/references/surfaces-and-design.md +16 -16
  344. package/skills/create-package/references/team-and-workflows.md +2 -2
  345. package/skills/create-package/references/test-and-ship.md +21 -21
  346. package/skills/create-package/references/tools-and-schemas.md +5 -5
  347. package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +79 -65
  348. package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
  349. package/skills/git/SKILL.md +53 -51
  350. package/skills/manage-files/SKILL.md +22 -25
  351. package/skills/manage-sources/SKILL.md +128 -87
  352. package/skills/manage-uri-policy/SKILL.md +18 -15
  353. package/skills/organize/SKILL.md +1 -1
  354. package/skills/sync/SKILL.md +3 -3
  355. package/tools/fs_list.json +1 -1
  356. package/tools/fs_read.json +1 -1
  357. package/tools/fs_search.json +1 -1
  358. package/tools/fs_write.json +1 -1
  359. package/workflows/sources-digest.json +1 -1
  360. package/dist/src/connectors/LocalDiskConnector.d.ts +0 -171
  361. package/dist/src/connectors/LocalDiskConnector.d.ts.map +0 -1
  362. package/dist/src/connectors/LocalDiskConnector.js +0 -1233
  363. package/dist/src/connectors/LocalDiskConnector.js.map +0 -1
  364. package/docs/drive-overview.md +0 -59
  365. package/skills/manage-files/scripts/copy.sh +0 -7
  366. package/skills/manage-files/scripts/create.sh +0 -7
  367. package/skills/manage-files/scripts/mkdir.sh +0 -7
  368. package/skills/manage-files/scripts/modify.sh +0 -7
  369. package/skills/manage-files/scripts/upload.sh +0 -16
  370. package/skills/manage-files/scripts/write.sh +0 -29
  371. package/skills/manage-sources/scripts/reembed.sh +0 -23
@@ -29,8 +29,8 @@ import {
29
29
  Trash2,
30
30
  type LucideIcon,
31
31
  } from 'lucide-react';
32
- import { meetsRequires } from '@neuralis/package-system/access';
33
- import { SkillLauncher } from '@neuralis/package-system/client';
32
+ import { isOwnerOrAdminEffective, meetsRequires } from '@neuralis/package-system/access';
33
+ import { SkillLauncher, createPackageApi } from '@neuralis/package-system/client';
34
34
  import { cn } from '../lib/cn';
35
35
  import {
36
36
  listSourceConfigs,
@@ -46,23 +46,23 @@ import {
46
46
  getSyncStatus,
47
47
  startReembed,
48
48
  resetSync,
49
- browseDirectories,
50
49
  listSourceKinds,
51
50
  setSourceEnabled,
52
- listDiscoverableSources,
53
51
  fetchSourceCounts,
52
+ browseDirectories,
54
53
  type SourceKindRecord,
55
54
  type DiscoverableSourceSuggestion,
56
55
  } from '../api';
57
- import { dimmedSourceTone, getSourceVisualTone, sourceLiveReasonLabel } from '../lib/treeVisualState';
58
- import { rootFieldMeta } from '../lib/sourceKindForm';
56
+ import { dimmedSourceTone, getSourceVisualTone, sourceLiveReasonLabel, sourceWatchBadge, syncErrorLabel } from '../lib/treeVisualState';
57
+ import { configFields, fieldControl, kindSummary, rootFieldMeta, selectOptionsFrom, transportChosen, type ConfigField, type OptionsRoute, type SelectOption } from '../lib/sourceKindForm';
59
58
  import { resolveIconWithFallback, resolveLucideIcon } from '../lib/iconResolver';
60
- import { useFilesystemStore } from '../store/filesystemStore';
59
+ import { discoverableKey, useFilesystemStore } from '../store/filesystemStore';
61
60
  import { fileKey, sourceFromUri, sourceRootUri } from '../store/helpers';
62
61
  import { VectorHealthPanel } from './VectorHealthPanel';
63
62
  import { PolicyEditor } from './PolicyEditor';
64
63
  import { SourceDeleteConfirm } from './SourceDeleteConfirm';
65
64
  import { ReembedDialog } from './ReembedDialog';
65
+ import { DEVICE_FOLDER_NOT_ALLOWED, DeviceFolderAsk, FolderPicker, type FolderSuggestion } from './FolderPicker';
66
66
  import { useFilesystemHost } from '../FilesystemHost';
67
67
  import type {
68
68
  SourceConfigRecord,
@@ -115,10 +115,38 @@ type AddDraftState = {
115
115
  * in the Policy editor after saving.
116
116
  */
117
117
  policies: PathPermissionRecord[];
118
- /** R2b — create this local source as a package source too (owner/admin). */
119
- recognizesPackages: boolean;
118
+ /** R2b — the folder's package folders, one per line (`.` = the whole folder; owner/admin). */
119
+ packageFolders: string;
120
+ /** The chosen device's claimed platform (its WHERE option's), for the folder hint; never submitted. */
121
+ transportPlatform: string;
120
122
  };
121
123
 
124
+ /** No suggestions held for the current pair — one stable reference, so a selector never churns. */
125
+ const NO_SUGGESTIONS: readonly DiscoverableSourceSuggestion[] = [];
126
+
127
+ /** The ONE folder kind (brain-core's connector; the stored `device` picks the transport). */
128
+ const HOST_KIND = 'host';
129
+
130
+ /**
131
+ * `packageFolders` ⇄ the one-per-line field both forms show: `.` (or `/`) is the
132
+ * whole folder (`''`), every other line a folder relative to the source root,
133
+ * surrounding slashes dropped. The server validates what this produces.
134
+ */
135
+ function parsePackageFolders(text: string): string[] {
136
+ const out: string[] = [];
137
+ for (const raw of text.split(/\r?\n/)) {
138
+ const line = raw.trim();
139
+ if (!line) continue;
140
+ const folder = line === '.' || line === '/' ? '' : line.replace(/^\/+|\/+$/g, '');
141
+ if (!out.includes(folder)) out.push(folder);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function formatPackageFolders(folders: readonly string[] | undefined): string {
147
+ return (folders ?? []).map((folder) => (folder === '' ? '.' : folder)).join('\n');
148
+ }
149
+
122
150
  const DEFAULT_SYNC: NonNullable<UpdateSourceConfigRequest['sync']> = {
123
151
  exclude: [],
124
152
  trigger: 'manual',
@@ -210,8 +238,14 @@ function suggestedSourceKey(params: {
210
238
  return `${base}-${ids.agentSlug || shortToken(ids.agentId)}`;
211
239
  }
212
240
 
213
- function scopeOptions(meta: SourceKindRecord | null): SourceScopeKind[] {
214
- return meta?.allowedScopes?.length ? meta.allowedScopes : ['project', 'user', 'agent'];
241
+ /**
242
+ * The scopes the form offers. A DEVICE folder (a transport chosen) is the
243
+ * attaching owner's user or the project's — never an agent's — mirroring the
244
+ * server's refusal, so the form never offers what the create would 400.
245
+ */
246
+ function scopeOptions(meta: SourceKindRecord | null, config: Readonly<Record<string, unknown>> = {}): SourceScopeKind[] {
247
+ const all: SourceScopeKind[] = meta?.allowedScopes?.length ? meta.allowedScopes : ['project', 'user', 'agent'];
248
+ return transportChosen(meta, config) ? all.filter((scope) => scope !== 'agent') : all;
215
249
  }
216
250
 
217
251
  function supportsSync(meta: SourceKindRecord | null): boolean {
@@ -246,8 +280,13 @@ function defaultAccess(meta: SourceKindRecord | null): { read: boolean; write: b
246
280
  };
247
281
  }
248
282
 
249
- function buildAddDraft(meta: SourceKindRecord | null, ids: SourceKeyIds): AddDraftState {
250
- const scope = meta?.defaultScope ?? 'project';
283
+ /**
284
+ * A fresh add-form draft for `meta`. `scopeHint` is the Sources list tab the
285
+ * person is on: the new source starts there when the kind allows that scope,
286
+ * otherwise at the kind's own default.
287
+ */
288
+ function buildAddDraft(meta: SourceKindRecord | null, ids: SourceKeyIds, scopeHint?: SourceScopeKind): AddDraftState {
289
+ const scope = scopeHint && meta?.scopeSelectable && scopeOptions(meta).includes(scopeHint) ? scopeHint : meta?.defaultScope ?? 'project';
251
290
  const access = defaultAccess(meta);
252
291
  return {
253
292
  // No folder picked yet → base falls back to the kind scheme.
@@ -262,7 +301,8 @@ function buildAddDraft(meta: SourceKindRecord | null, ids: SourceKeyIds): AddDra
262
301
  description: '',
263
302
  config: defaultConfig(meta),
264
303
  policies: [],
265
- recognizesPackages: false,
304
+ packageFolders: '',
305
+ transportPlatform: '',
266
306
  };
267
307
  }
268
308
 
@@ -286,8 +326,8 @@ function buildDraft(c: SourceConfigRecord): UpdateSourceConfigRequest {
286
326
  source: c.source,
287
327
  permissions: normalizePerms(c.permissions),
288
328
  description: c.description ?? '',
289
- // R2b — include in the draft so the toggle participates in isDirty + PATCH.
290
- recognizesPackages: c.recognizesPackages ?? false,
329
+ // R2b — include in the draft so the list participates in isDirty + PATCH.
330
+ packageFolders: [...(c.packageFolders ?? [])],
291
331
  ...(c.sync ? {
292
332
  sync: {
293
333
  exclude: [...(c.sync.exclude ?? [])],
@@ -358,7 +398,7 @@ export function binarySkipLine(count: number | undefined): string | null {
358
398
  * source's own exclude/include globs and its scan cap (`usecases/list.ts`
359
399
  * `computeSourcesRegistry` pushes them into `connector.count()`), so editing
360
400
  * them moves the count immediately. Everything else a save can carry —
361
- * description, trigger cadence, `recognizesPackages`, the whole permissions
401
+ * description, trigger cadence, `packageFolders`, the whole permissions
362
402
  * tree — leaves both numbers where they were: the indexed count is a
363
403
  * project-scoped `countByFilter` with no per-caller policy input, and the
364
404
  * on-disk count is physical truth.
@@ -403,8 +443,12 @@ function triggerLabel(trigger: string): string {
403
443
  }
404
444
 
405
445
  function describeConn(c: SourceConfigRecord, meta: SourceKindRecord | null): string {
446
+ if (c.root_effective) return c.root_effective;
406
447
  const cfg = (c.connection.config ?? {}) as Record<string, unknown>;
407
- // Prefer a schema-hint-driven summary: show the first string/number value.
448
+ // A folder kind names its folder: the form stores the transport (`device`)
449
+ // first, so "the first value" would show a device id where the path belongs.
450
+ if (typeof cfg.root === 'string' && cfg.root.length > 0) return cfg.root;
451
+ // Otherwise a schema-hint-driven summary: show the first string/number value.
408
452
  for (const [key, value] of Object.entries(cfg)) {
409
453
  if (typeof value === 'string' && value.length > 0) return value;
410
454
  if (typeof value === 'number') return String(value);
@@ -412,6 +456,12 @@ function describeConn(c: SourceConfigRecord, meta: SourceKindRecord | null): str
412
456
  return meta?.label ?? c.connection.kind;
413
457
  }
414
458
 
459
+ /** A source this server opens through its container half (`root_effective` is the stored `root`) that has a machine half. */
460
+ function isDockerBind(c: SourceConfigRecord): boolean {
461
+ const { root, hostRoot } = (c.connection?.config ?? {}) as { root?: unknown; hostRoot?: unknown };
462
+ return Boolean(hostRoot) && (c.root_effective === undefined || c.root_effective === root);
463
+ }
464
+
415
465
  function sourceSummaryToObservedConfig(
416
466
  summary: SourceInfo,
417
467
  meta: SourceKindRecord | null,
@@ -435,6 +485,7 @@ function sourceSummaryToObservedConfig(
435
485
  ...(summary.origin_package ? { origin_package: summary.origin_package } : {}),
436
486
  ...(summary.live !== undefined ? { live: summary.live } : {}),
437
487
  ...(summary.live_reason ? { live_reason: summary.live_reason } : {}),
488
+ ...(summary.live_not_ready ? { live_not_ready: summary.live_not_ready } : {}),
438
489
  };
439
490
  }
440
491
 
@@ -479,23 +530,28 @@ export function SourcesPanel(props: Props) {
479
530
  const setPendingSourceSuggestion = useFilesystemStore((s) => s.setPendingSourceSuggestion);
480
531
  const refresh = useFilesystemStore((s) => s.refresh);
481
532
  const sourceSummaries = useFilesystemStore((s) => s.sources);
533
+ // A `devices` frame bumps it: the device `select` re-reads its options.
534
+ const deviceRevision = useFilesystemStore((s) => s.deviceRevision);
482
535
 
483
536
  // Source-key suggestion inputs: the signed-in user (for the user-scope
484
537
  // qualifier) and the active agent's name (for the agent-scope qualifier).
485
538
  const host = useFilesystemHost();
486
539
  const agentsState = host.useAgents();
487
540
  const userSlug = useMemo(() => deriveUserSlug(host.currentUser), [host.currentUser]);
488
- // R2b — owner/admin-effective (ordinal priority ≤ 2) gates the
489
- // `recognizesPackages` toggle in the UI. Server is the enforcement floor; this
490
- // only HIDES the control. Undefined priority (no signal) ⇒ hide (deny-by-default).
491
- const isOwnerEffective = (host.currentRole?.priority ?? 99) <= 2;
492
- // warded-wren — affordance mirror for the `drive.mount` gate that now covers
541
+ // R2b — the package-folders field shows for exactly the callers the server's
542
+ // gate admits: the SAME kernel predicate (`isOwnerOrAdminEffective`), so the
543
+ // hide cannot drift from the lock. The server stays the floor; no signal ⇒ hide.
544
+ const isOwnerEffective = isOwnerOrAdminEffective({
545
+ priority: host.currentRole?.priority,
546
+ grantedFeatures: host.grantedFeatures,
547
+ });
548
+ // warded-wren — affordance mirror for the `sources.mount` gate that now covers
493
549
  // browse and the discoverable list. Same shape as `FileTree`'s: the ONE shared
494
550
  // kernel predicate, a copy at the boundary because the host keeps the grant
495
551
  // list readonly, and it authorizes NOTHING — the routes stay the boundary.
496
552
  // Hidden, never a disabled row (the S2 discipline).
497
553
  const canMount = useMemo(
498
- () => meetsRequires({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, { features: ['drive.mount'] }),
554
+ () => meetsRequires({ grantedFeatures: [...(host.grantedFeatures ?? [])] }, { features: ['sources.mount'] }),
499
555
  [host.grantedFeatures],
500
556
  );
501
557
  const agentSlug = useMemo(() => {
@@ -561,18 +617,18 @@ export function SourcesPanel(props: Props) {
561
617
  const healthOpen = useFilesystemStore((s) => s.vectorHealthOpen);
562
618
  const setVectorHealthOpen = useFilesystemStore((s) => s.setVectorHealthOpen);
563
619
  const [mountOpen, setMountOpen] = useState(false);
564
- const [browseOpen, setBrowseOpen] = useState(false);
565
- const [browseEntries, setBrowseEntries] = useState<Array<{ name: string; path: string }>>([]);
566
- const [browsePath, setBrowsePath] = useState('/');
567
- const [browseLoading, setBrowseLoading] = useState(false);
568
- const [browseError, setBrowseError] = useState<string | null>(null);
569
- const [dockerMounts, setDockerMounts] = useState<Array<{ name: string; path: string }>>([]);
570
- const [dockerMountsLoading, setDockerMountsLoading] = useState(false);
620
+ /** The folder picker under the Folder field (`FolderPicker` owns its listing state). */
621
+ const [pickerOpen, setPickerOpen] = useState(false);
622
+ /** The device folder a Create found outside that device's allowed folders: ask, or attach anyway. */
623
+ const [deviceGuard, setDeviceGuard] = useState<string | null>(null);
571
624
  const [kinds, setKinds] = useState<SourceKindRecord[]>([]);
572
- const [addKind, setAddKind] = useState<string>('local');
625
+ const [addKind, setAddKind] = useState<string>(HOST_KIND);
573
626
  const [kindsError, setKindsError] = useState<string | null>(null);
574
- // W3E — discoverable (well-known but unattached) source roots.
575
- const [discoverable, setDiscoverable] = useState<DiscoverableSourceSuggestion[]>([]);
627
+ // W3E — discoverable (well-known but unattached) source roots: the window
628
+ // store's ONE copy, shared with the tree's Discover dock (`loadDiscoverable`).
629
+ const loadDiscoverable = useFilesystemStore((s) => s.loadDiscoverable);
630
+ const discoverableKeyNow = discoverableKey(agentId, projectId ?? undefined);
631
+ const discoverable = useFilesystemStore((s) => (s.discoverable?.key === discoverableKeyNow ? s.discoverable.suggestions : NO_SUGGESTIONS));
576
632
 
577
633
  // Load registered source kinds once per mount — this is what powers the
578
634
  // schema-driven picker (replacing the old hardcoded KIND_META).
@@ -583,10 +639,10 @@ export function SourcesPanel(props: Props) {
583
639
  if (cancelled) return;
584
640
  setKinds(res.kinds ?? []);
585
641
  setKindsError(null);
586
- const preferred = res.kinds?.find((k) => k.kind === 'local') ?? res.kinds?.[0] ?? null;
642
+ const preferred = res.kinds?.find((k) => k.kind === HOST_KIND) ?? res.kinds?.[0] ?? null;
587
643
  if (preferred) {
588
644
  setAddKind(preferred.kind);
589
- const draft = buildAddDraft(preferred, sourceKeyIds);
645
+ const draft = buildAddDraft(preferred, sourceKeyIds, activeScope);
590
646
  autoKeyRef.current = draft.key;
591
647
  setAddDraft(draft);
592
648
  }
@@ -605,13 +661,22 @@ export function SourcesPanel(props: Props) {
605
661
  }, [kinds]);
606
662
  const selectedKind = kindByName.get(addKind) ?? null;
607
663
  const selectedKindSupportsSync = supportsSync(selectedKind);
608
- const rootField = rootFieldMeta(selectedKind);
664
+ const rootField = rootFieldMeta(selectedKind, addDraft.config, addDraft.transportPlatform);
609
665
  const selectedKindNeedsPath = rootField.needed;
610
- // The container-plane affordances: the `/mounts` volume picker, the
611
- // `neuralis:mount` callout, and Browse (which readdirs in the SERVER process,
612
- // i.e. the wrong machine for a host-plane root — a host root's authority is
613
- // the operator's broker ceiling, not this process's view of the disk).
614
- const showContainerPathChrome = selectedKindNeedsPath && rootField.container;
666
+ // The non-root fields in the one-page order: WHERE first (it decides what the
667
+ // folder means), the plain fields, the advanced ones folded.
668
+ const formFields = configFields(selectedKind, addDraft.config);
669
+ // The device the folder lives on — the transport field's value — or `null` for this server.
670
+ const chosenDeviceValue = formFields.transport[0] ? addDraft.config[formFields.transport[0].key] : undefined;
671
+ const chosenDevice = typeof chosenDeviceValue === 'string' && chosenDeviceValue ? chosenDeviceValue : null;
672
+ // The picker's start on this server: the discoverable folders of the host kind
673
+ // (already loaded with the list — no request of its own).
674
+ const folderSuggestions = useMemo<FolderSuggestion[]>(
675
+ () => discoverable
676
+ .filter((s) => s.suggestedKind === HOST_KIND && typeof s.suggestedConfig.root === 'string')
677
+ .map((s) => ({ name: s.label, path: s.suggestedConfig.root as string })),
678
+ [discoverable],
679
+ );
615
680
 
616
681
  /**
617
682
  * Open the Add-Source form prefilled from a discoverable suggestion.
@@ -694,33 +759,34 @@ export function SourcesPanel(props: Props) {
694
759
  /** Whether the key field still holds an auto-suggestion (safe to re-suggest). */
695
760
  const keyIsUntouched = (key: string) => key === '' || key === autoKeyRef.current;
696
761
 
697
- const load = useCallback(async () => {
762
+ const load = useCallback(async (fresh = true) => {
698
763
  if (!projectId) return; // Don't fetch without projectId — it would query the wrong directory
699
764
  setLoading(true);
700
765
  setError(null);
701
766
  try {
702
- // W3E — load source configs and discoverable suggestions in parallel.
703
- const [next, discoverableRes] = await Promise.all([
767
+ // W3E — load source configs and discoverable suggestions in parallel. The
768
+ // suggestions go through the store's one owner: on open an answer the
769
+ // tree's dock already holds (or is fetching) is reused; every other call
770
+ // (Refresh, after a mutation) re-reads, so an attached root drops out.
771
+ const [next] = await Promise.all([
704
772
  listSourceConfigs({
705
773
  agentId,
706
774
  projectId,
707
775
  scope: activeScope,
708
776
  }, client),
709
- listDiscoverableSources({ agentId, projectId }, client)
710
- .catch(() => ({ suggestions: [] as DiscoverableSourceSuggestion[] })),
777
+ loadDiscoverable({ agentId, projectId }, { fresh }),
711
778
  ]);
712
779
  setConfigs(next);
713
780
  setDrafts(Object.fromEntries(next.map((c) => [c.source, buildDraft(c)])));
714
781
  setExpanded((prev) => Object.fromEntries(next.map((c) => [c.source, prev[c.source] ?? false])));
715
- setDiscoverable(discoverableRes.suggestions ?? []);
716
782
  } catch (err) {
717
783
  setError(err instanceof Error ? err.message : 'Failed to load sources');
718
784
  } finally {
719
785
  setLoading(false);
720
786
  }
721
- }, [activeScope, agentId, projectId]);
787
+ }, [activeScope, agentId, projectId, loadDiscoverable]);
722
788
 
723
- useEffect(() => { if (open) void load(); }, [open, load]);
789
+ useEffect(() => { if (open) void load(false); }, [open, load]);
724
790
 
725
791
  // On panel open, do a COUNT-FREE refresh: the connector-first list merge still
726
792
  // populates `sourceSummaries` (`s.sources`) with source discovery (observed-only
@@ -790,21 +856,6 @@ export function SourcesPanel(props: Props) {
790
856
  return () => clearTimeout(t);
791
857
  }, [successMsg]);
792
858
 
793
- // Docker mode: load available volume mounts when +Source form opens
794
- useEffect(() => {
795
- if (!showAddForm || !isDocker || !canMount) return;
796
- setDockerMountsLoading(true);
797
- browseDirectories({ path: '/mounts', agentId, ...(projectId ? { projectId } : {}) }, client)
798
- .then((result) => {
799
- // Filter out volumes already configured as sources
800
- const configuredSources = new Set(configs.map((c) => c.source));
801
- const unmounted = result.entries.filter((e) => !configuredSources.has(e.name));
802
- setDockerMounts(unmounted);
803
- })
804
- .catch(() => setDockerMounts([]))
805
- .finally(() => setDockerMountsLoading(false));
806
- }, [showAddForm, isDocker, canMount, agentId, projectId, configs]);
807
-
808
859
  const merged = useMemo(() => {
809
860
  const seen = new Set<string>();
810
861
  const rows: Array<SourceConfigRecord & {
@@ -833,6 +884,8 @@ export function SourcesPanel(props: Props) {
833
884
  // the row icon + name when the connector can't reach its backing store.
834
885
  live: s?.live,
835
886
  live_reason: s?.live_reason,
887
+ live_not_ready: s?.live_not_ready,
888
+ watch: s?.watch,
836
889
  kind_icon: s?.kind_icon,
837
890
  };
838
891
  });
@@ -840,7 +893,9 @@ export function SourcesPanel(props: Props) {
840
893
  if (activeScope === 'project') {
841
894
  for (const summary of sourceSummaries) {
842
895
  const source = sourceFromUri(summary.uri_root);
843
- if (!source || seen.has(source)) continue;
896
+ // A source configured at the user or agent scope is edited on its own
897
+ // tab — only one with no config at all is runtime-observed here.
898
+ if (!source || seen.has(source) || (summary.config_scope !== undefined && summary.config_scope !== 'project')) continue;
844
899
  const meta = kindByName.get(summary.source_kind) ?? null;
845
900
  const counts = countsBySource[source] ?? summary;
846
901
  rows.push({
@@ -895,13 +950,13 @@ export function SourcesPanel(props: Props) {
895
950
  // "no change" server-side and would preserve the old config. Send an
896
951
  // explicit clear flag when the source HAD sync and the draft turned it off.
897
952
  const clearSync = !draft.sync && !!config.sync;
898
- // R2b — `recognizesPackages` is owner/admin-effective + local-only on the
899
- // server. `buildDraft` always carries it, so STRIP it for a non-owner (or
900
- // non-local source) to avoid tripping the server's owner-only gate on an
953
+ // R2b — `packageFolders` is owner/admin-effective + folder-kind-only on
954
+ // the server. `buildDraft` always carries it, so STRIP it for a non-owner
955
+ // (or another kind) to avoid tripping the server's owner-only gate on an
901
956
  // unrelated description/sync edit. Owner-effective callers send the real value.
902
- const canEditRecognizes = isOwnerEffective && config.connection.kind === 'local';
903
- const { recognizesPackages: _rp, ...draftRest } = draft;
904
- const payload = canEditRecognizes ? draft : draftRest;
957
+ const canEditPackageFolders = isOwnerEffective && config.connection.kind === HOST_KIND;
958
+ const { packageFolders: _pf, ...draftRest } = draft;
959
+ const payload = canEditPackageFolders ? draft : draftRest;
905
960
  const updated = await updateSourceConfig({
906
961
  agentId,
907
962
  ...(projectId ? { projectId } : {}),
@@ -1002,17 +1057,18 @@ export function SourcesPanel(props: Props) {
1002
1057
  // the same time. Keep every hook above the guard.
1003
1058
  if (!open) return null;
1004
1059
 
1005
- const handleSync = async (source: string) => {
1060
+ const handleSync = async (source: string, confirmEmpty = false) => {
1006
1061
  setSyncingSource(source);
1007
1062
  setError(null);
1008
1063
  try {
1009
- await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source }, client);
1064
+ await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source, ...(confirmEmpty ? { confirmEmpty } : {}) }, client);
1010
1065
  await pollSyncJob(source);
1011
1066
  await load();
1012
1067
  await refresh({ agentId, projectId, withCounts: true });
1013
1068
  clearCachedCounts(source);
1014
1069
  } catch (err) {
1015
- setError(err instanceof Error ? err.message : 'Sync failed');
1070
+ await load(); // the row's last-sync error (and its Sync anyway) comes from the reloaded config
1071
+ setError(err instanceof Error ? syncErrorLabel(err.message) : 'Sync failed');
1016
1072
  } finally {
1017
1073
  setSyncingSource(null);
1018
1074
  }
@@ -1147,25 +1203,7 @@ export function SourcesPanel(props: Props) {
1147
1203
  }
1148
1204
  };
1149
1205
 
1150
- const openBrowser = async (path: string) => {
1151
- setBrowseOpen(true);
1152
- setBrowseLoading(true);
1153
- setBrowseError(null);
1154
- try {
1155
- const result = await browseDirectories({ path, agentId, ...(projectId ? { projectId } : {}) }, client);
1156
- setBrowseEntries(result.entries);
1157
- setBrowsePath(path);
1158
- } catch {
1159
- // One stable sentence, matching the route: the server never returns the
1160
- // path or the errno, so there is nothing here worth surfacing verbatim.
1161
- setBrowseError('That path is not available.');
1162
- setBrowseEntries([]);
1163
- } finally {
1164
- setBrowseLoading(false);
1165
- }
1166
- };
1167
-
1168
- const selectBrowsePath = (path: string) => {
1206
+ const selectPickedFolder = (path: string) => {
1169
1207
  setAddDraft((d) => {
1170
1208
  // Re-suggest the key from the picked folder's name — but only while the
1171
1209
  // key is still an auto-suggestion. A manually-typed key is preserved.
@@ -1174,18 +1212,88 @@ export function SourcesPanel(props: Props) {
1174
1212
  autoKeyRef.current = key;
1175
1213
  return { ...d, path, key };
1176
1214
  });
1177
- setBrowseOpen(false);
1215
+ setPickerOpen(false);
1216
+ };
1217
+
1218
+ /** One non-root config field (WHERE, a plain field, an advanced one) — its control from the declaration. */
1219
+ const renderConfigField = ({ key, prop, label, help, placeholder }: ConfigField) => {
1220
+ const field = fieldControl(prop, selectedKind?.packageId ?? '');
1221
+ const isTransport = formFields.transport.some((f) => f.key === key);
1222
+ const setValue = (value: string, option?: SelectOption) => {
1223
+ setAddDraft((prev) => {
1224
+ const d = isTransport ? { ...prev, transportPlatform: option?.platform ?? '' } : prev;
1225
+ const config = { ...d.config, [key]: value };
1226
+ const before = transportChosen(selectedKind, d.config);
1227
+ const after = transportChosen(selectedKind, config);
1228
+ if (before === after) return { ...d, config };
1229
+ // Choosing a device makes the folder its owner's own by default
1230
+ // (the server's default too): user scope, never agent, and
1231
+ // nothing granted until the owner widens it — a device folder
1232
+ // at project scope would otherwise open somebody's machine to
1233
+ // every member the moment it exists. Back to this server: the
1234
+ // kind's own defaults.
1235
+ return after
1236
+ ? { ...d, config, scope: 'user' as SourceScopeKind, read: false, write: false, exec: false }
1237
+ : { ...d, config, scope: selectedKind?.defaultScope ?? 'project', ...defaultAccess(selectedKind) };
1238
+ });
1239
+ // A different machine: the open picker listed the other one.
1240
+ if (isTransport) setPickerOpen(false);
1241
+ };
1242
+ return (
1243
+ <div key={key} className="space-y-1">
1244
+ <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{label}</div>
1245
+ {field.control === 'select' ? (
1246
+ <OptionsRouteSelect
1247
+ route={field.options}
1248
+ {...(field.emptyLabel ? { emptyLabel: field.emptyLabel } : {})}
1249
+ revision={deviceRevision}
1250
+ projectId={projectId}
1251
+ agentId={agentId}
1252
+ value={String(addDraft.config[key] ?? '')}
1253
+ onChange={setValue}
1254
+ />
1255
+ ) : (
1256
+ <input
1257
+ type={field.numeric ? 'number' : 'text'}
1258
+ value={String(addDraft.config[key] ?? '')}
1259
+ onChange={(e) => setValue(e.target.value)}
1260
+ placeholder={placeholder ?? key}
1261
+ className={inputCls}
1262
+ />
1263
+ )}
1264
+ {help ? <div className="text-[8px] text-white/30 italic">{help}</div> : null}
1265
+ {field.control === 'text' && field.refused ? (
1266
+ <div className="text-[9px] text-amber-300/80">{field.refused}</div>
1267
+ ) : null}
1268
+ </div>
1269
+ );
1178
1270
  };
1179
1271
 
1180
- const handleAddSource = async () => {
1272
+ const handleAddSource = async (attachAnyway = false) => {
1181
1273
  if (!addDraft.key || (selectedKindNeedsPath && !addDraft.path)) return;
1182
1274
  setAddCreating(true);
1183
1275
  setError(null);
1184
1276
  try {
1277
+ // A folder outside what the device allows would attach and then answer
1278
+ // `host_fs_denied` on every read: say so BEFORE creating and offer the
1279
+ // ask — attaching first and granting later stays a choice. One browse of
1280
+ // that folder per Create (the device's clamp answers `ceiling_denied`
1281
+ // whether or not it exists); any other answer creates as before.
1282
+ if (chosenDevice && selectedKindNeedsPath && !attachAnyway) {
1283
+ const verdict = await browseDirectories({ path: addDraft.path, agentId, ...(projectId ? { projectId } : {}), device: chosenDevice }, client).catch(() => null);
1284
+ if (verdict?.state === 'ceiling_denied') {
1285
+ setDeviceGuard(addDraft.path);
1286
+ return;
1287
+ }
1288
+ }
1289
+ setDeviceGuard(null);
1290
+ // An optional field left empty is not sent: an empty `device` would be a
1291
+ // malformed device id, not "this server".
1185
1292
  const config = {
1186
- ...addDraft.config,
1293
+ ...Object.fromEntries(Object.entries(addDraft.config).filter(([, value]) => value !== '')),
1187
1294
  ...(selectedKindNeedsPath ? { root: addDraft.path } : {}),
1188
1295
  };
1296
+ const packageFolders = parsePackageFolders(addDraft.packageFolders);
1189
1297
  await createSource({
1190
1298
  agentId,
1191
1299
  ...(projectId ? { projectId } : {}),
@@ -1208,15 +1316,16 @@ export function SourcesPanel(props: Props) {
1208
1316
  ...(linesToList(addDraft.include).length > 0 ? { include: linesToList(addDraft.include) } : {}),
1209
1317
  },
1210
1318
  } : {}),
1211
- // R2b — only send when an owner/admin-effective caller opted a local
1212
- // source in. The server re-gates (owner/admin + local-only).
1213
- ...(isOwnerEffective && addKind === 'local' && addDraft.recognizesPackages
1214
- ? { recognizesPackages: true }
1319
+ // R2b — only send when an owner/admin-effective caller listed package
1320
+ // folders. The server re-gates (owner/admin + folder kind only).
1321
+ ...(isOwnerEffective && addKind === HOST_KIND && packageFolders.length > 0
1322
+ ? { packageFolders }
1215
1323
  : {}),
1216
1324
  }, client);
1217
1325
  setShowAddForm(false);
1326
+ setPickerOpen(false);
1218
1327
  {
1219
- const reset = buildAddDraft(selectedKind, sourceKeyIds);
1328
+ const reset = buildAddDraft(selectedKind, sourceKeyIds, activeScope);
1220
1329
  autoKeyRef.current = reset.key;
1221
1330
  setAddDraft(reset);
1222
1331
  }
@@ -1249,7 +1358,16 @@ export function SourcesPanel(props: Props) {
1249
1358
  <div className="inline-flex items-center gap-0.5">
1250
1359
  <button
1251
1360
  type="button"
1252
- onClick={() => setShowAddForm((v) => !v)}
1361
+ onClick={() => {
1362
+ // A new source starts on the Sources tab the person is looking at
1363
+ // (when the kind allows that scope and the draft is untouched).
1364
+ if (!showAddForm && keyIsUntouched(addDraft.key) && !addDraft.path) {
1365
+ const draft = buildAddDraft(selectedKind, sourceKeyIds, activeScope);
1366
+ autoKeyRef.current = draft.key;
1367
+ setAddDraft(draft);
1368
+ }
1369
+ setShowAddForm((v) => !v);
1370
+ }}
1253
1371
  className="h-7 px-2 rounded-l-md rounded-r-none text-cyan-300 hover:text-cyan-200 hover:bg-white/[0.05] transition inline-flex items-center gap-1 text-[10px] font-medium"
1254
1372
  title="Add source"
1255
1373
  >
@@ -1285,7 +1403,7 @@ export function SourcesPanel(props: Props) {
1285
1403
 
1286
1404
  {/* Description */}
1287
1405
  <div className="mt-2 text-[10px] text-white/35 italic">
1288
- Add filesystem-style sources from Neuralis packages. A source URI such as <code className="font-mono">neuralis://</code> or <code className="font-mono">machine-user://</code> is the stable handle agents use with fs_* tools.
1406
+ Add filesystem-style sources from Neuralis packages. A source URI such as <code className="font-mono">neuralis://</code> or <code className="font-mono">&lt;machine-source-slug&gt;://</code> is the stable handle agents use with fs_* tools.
1289
1407
  </div>
1290
1408
 
1291
1409
  <div className="mt-2 flex items-center gap-1 rounded-md bg-white/[0.025] p-1">
@@ -1314,13 +1432,14 @@ export function SourcesPanel(props: Props) {
1314
1432
  <div className="mt-2 rounded-md bg-red-500/8 px-3 py-2 text-[10px] text-red-200/80">{error}</div>
1315
1433
  )}
1316
1434
 
1317
- {/* Source wizard (inline) */}
1435
+ {/* Source wizard (inline) — one page, in the order a person decides:
1436
+ kind → where → folder → who/what → sync → advanced. */}
1318
1437
  {showAddForm && (
1319
1438
  <div className="mt-2 rounded-lg bg-white/[0.03] border border-white/[0.08] p-3 space-y-2">
1320
1439
  <div className="text-[11px] font-semibold text-white/80">Add Source</div>
1321
1440
  {kindsError && (
1322
1441
  <div className="text-[9px] text-rose-300/80 bg-rose-500/10 rounded px-2 py-1">
1323
- Could not load source kinds: {kindsError}. Falling back to "local".
1442
+ Could not load source kinds: {kindsError}. Falling back to the host folder kind.
1324
1443
  </div>
1325
1444
  )}
1326
1445
  {kinds.length > 1 && (
@@ -1332,7 +1451,8 @@ export function SourcesPanel(props: Props) {
1332
1451
  const nextKind = e.target.value;
1333
1452
  const nextMeta = kindByName.get(nextKind) ?? null;
1334
1453
  setAddKind(nextKind);
1335
- const draft = buildAddDraft(nextMeta, sourceKeyIds);
1454
+ setPickerOpen(false);
1455
+ const draft = buildAddDraft(nextMeta, sourceKeyIds, activeScope);
1336
1456
  autoKeyRef.current = draft.key;
1337
1457
  setAddDraft(draft);
1338
1458
  }}
@@ -1345,10 +1465,64 @@ export function SourcesPanel(props: Props) {
1345
1465
  ))}
1346
1466
  </select>
1347
1467
  <div className="text-[9px] text-white/30 italic">
1348
- {selectedKind?.description ?? ''}
1468
+ {kindSummary(selectedKind) ?? selectedKind?.description ?? ''}
1469
+ {selectedKind?.runtimeGated ? ' Serves only while its runtime runs.' : ''}
1470
+ </div>
1471
+ </div>
1472
+ )}
1473
+ {formFields.transport.map(renderConfigField)}
1474
+ <div className="text-[10px] text-white/35 italic">
1475
+ {!selectedKindNeedsPath
1476
+ ? 'This connector does not need a local path. Choose the scope; the package owns the runtime binding.'
1477
+ : !rootField.container
1478
+ ? 'A folder on your own paired device. Browse opens the typed folder, or starts from the folders that device allows this server; for another one, ask on that device (tray → Allow a folder…, or neuralis device grant).'
1479
+ : isDocker
1480
+ ? 'A folder under /mounts — this container’s bind mounts, or a folder inside one. Not there? Add a mount on the host (neuralis mount add) and restart, or attach it from your own device under Where.'
1481
+ : 'On this server: the project’s own folder (an owner may attach a protected zone). Another folder on your computer: choose your device under Where.'}
1482
+ </div>
1483
+ {selectedKindNeedsPath && (
1484
+ <div className="space-y-1">
1485
+ <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{rootField.label}</div>
1486
+ <div className="flex gap-1">
1487
+ <input
1488
+ type="text"
1489
+ value={addDraft.path}
1490
+ onChange={(e) => setAddDraft((d) => ({ ...d, path: e.target.value }))}
1491
+ placeholder={isDocker && rootField.container ? '/mounts/<name>' : rootField.placeholder}
1492
+ className={cn(inputCls, 'flex-1')}
1493
+ />
1494
+ {/* Browse: a device folder through that device's broker (its
1495
+ owner only — the route refuses anyone else), a folder here
1496
+ only for a caller who could attach what they find. */}
1497
+ {rootField.browse && (chosenDevice || canMount) && (
1498
+ <button
1499
+ type="button"
1500
+ onClick={() => setPickerOpen((v) => !v)}
1501
+ className="h-7 px-2 rounded-md bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 hover:text-white/90 transition shrink-0"
1502
+ title="Browse folders"
1503
+ >
1504
+ <FolderOpen className="h-3.5 w-3.5" />
1505
+ </button>
1506
+ )}
1349
1507
  </div>
1350
1508
  </div>
1351
1509
  )}
1510
+ {pickerOpen && selectedKindNeedsPath && (
1511
+ <FolderPicker
1512
+ key={chosenDevice ?? 'server'}
1513
+ agentId={agentId}
1514
+ projectId={projectId}
1515
+ client={client}
1516
+ device={chosenDevice}
1517
+ isDocker={isDocker === true}
1518
+ startPath={addDraft.path}
1519
+ suggestions={chosenDevice ? [] : folderSuggestions}
1520
+ wantWrite={addDraft.write}
1521
+ onSelect={selectPickedFolder}
1522
+ onClose={() => setPickerOpen(false)}
1523
+ />
1524
+ )}
1525
+ {formFields.fields.map(renderConfigField)}
1352
1526
  {selectedKind?.scopeSelectable && (
1353
1527
  <div className="space-y-1">
1354
1528
  <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Scope</div>
@@ -1367,197 +1541,27 @@ export function SourcesPanel(props: Props) {
1367
1541
  }}
1368
1542
  className={selectCls}
1369
1543
  >
1370
- {scopeOptions(selectedKind).map((scope) => (
1544
+ {scopeOptions(selectedKind, addDraft.config).map((scope) => (
1371
1545
  <option key={scope} value={scope}>{scope}</option>
1372
1546
  ))}
1373
1547
  </select>
1374
1548
  </div>
1375
1549
  )}
1376
- {showContainerPathChrome && isDocker ? (
1377
- <>
1378
- <div className="text-[10px] text-amber-300/70 bg-amber-500/8 rounded px-2 py-1.5">
1379
- Docker mode: host folders still need the CLI mount script:{' '}
1380
- <code className="text-amber-200/90 font-mono">neuralis mount add</code>{' '}
1381
- on the host, then restart the container. Package sources like Machine do not require a host path here.
1382
- </div>
1383
- {/* Unmounted volume mounts */}
1384
- {dockerMountsLoading ? (
1385
- <div className="flex items-center gap-1.5 py-1">
1386
- <Loader2 className="h-3 w-3 animate-spin text-white/30" />
1387
- <span className="text-[9px] text-white/35">Scanning volume mounts...</span>
1388
- </div>
1389
- ) : dockerMounts.length > 0 ? (
1390
- <div className="space-y-1">
1391
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">
1392
- Available volume mounts (not yet configured)
1393
- </div>
1394
- {dockerMounts.map((mount) => (
1395
- <div key={mount.path} className="flex items-center gap-2 rounded px-2 py-1.5 bg-white/[0.02] hover:bg-white/[0.04] transition">
1396
- <Folder className="h-3 w-3 text-cyan-400/70 shrink-0" />
1397
- <span className="text-[10px] text-white/75 font-medium flex-1 truncate">{mount.name}</span>
1398
- <span className="text-[9px] text-white/30 font-mono truncate max-w-[120px]">{mount.path}</span>
1399
- <button
1400
- type="button"
1401
- onClick={() => {
1402
- setAddDraft((d) => ({ ...d, key: mount.name, path: mount.path }));
1403
- }}
1404
- className="px-2 py-0.5 rounded text-[8px] font-semibold text-cyan-300/80 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-400/20 transition shrink-0"
1405
- >
1406
- + Add
1407
- </button>
1408
- </div>
1409
- ))}
1410
- </div>
1411
- ) : (
1412
- <div className="text-[9px] text-white/30 italic">
1413
- All volume mounts are configured as sources.
1414
- </div>
1415
- )}
1416
- </>
1417
- ) : (
1418
- <div className="text-[10px] text-white/35 italic">
1419
- {!selectedKindNeedsPath
1420
- ? 'This connector does not need a local path. Choose the scope and URI scheme; the package owns the runtime binding.'
1421
- : rootField.container
1422
- ? 'Mount any folder from your system. The path must be accessible to the server process.'
1423
- : 'A path on the operator\u2019s host machine, reached through the host broker. It needs no volume mount here, and the broker ceiling still clamps what is reachable.'}
1424
- </div>
1425
- )}
1426
- <div className="space-y-2">
1427
- <div className={cn('grid gap-2', selectedKindNeedsPath ? 'grid-cols-2' : 'grid-cols-1')}>
1428
- <div className="space-y-1">
1429
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">URI scheme</div>
1430
- <input
1431
- type="text"
1432
- value={addDraft.key}
1433
- onChange={(e) => setAddDraft((d) => ({ ...d, key: e.target.value }))}
1434
- placeholder={computeSuggestedKey(addDraft.scope, addDraft.path)}
1435
- className={inputCls}
1436
- />
1437
- <div className="text-[8px] text-white/25 font-mono">{addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</div>
1438
- </div>
1439
- {selectedKindNeedsPath && (
1440
- <div className="space-y-1">
1441
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{rootField.label}</div>
1442
- <div className="flex gap-1">
1443
- <input
1444
- type="text"
1445
- value={addDraft.path}
1446
- onChange={(e) => setAddDraft((d) => ({ ...d, path: e.target.value }))}
1447
- placeholder={rootField.placeholder}
1448
- className={cn(inputCls, 'flex-1')}
1449
- />
1450
- {/* Browse: container-plane roots only (the route readdirs in
1451
- the SERVER process), outside Docker, and only for a caller
1452
- who could actually attach what they find. */}
1453
- {rootField.container && !isDocker && canMount && (
1454
- <button
1455
- type="button"
1456
- onClick={() => void openBrowser(addDraft.path || '/')}
1457
- className="h-7 px-2 rounded-md bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 hover:text-white/90 transition shrink-0"
1458
- title="Browse directories"
1459
- >
1460
- <FolderOpen className="h-3.5 w-3.5" />
1461
- </button>
1462
- )}
1463
- </div>
1464
- </div>
1465
- )}
1466
- </div>
1467
- {Object.entries(schemaObject(selectedKind).properties)
1468
- .filter(([key]) => key !== 'root')
1469
- .map(([key, prop]) => (
1470
- <div key={key} className="space-y-1">
1471
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">
1472
- {(prop as any)?.['x-neuralis']?.ui?.label ?? key}
1473
- </div>
1474
- <input
1475
- type={(prop as any).type === 'number' || (prop as any).type === 'integer' ? 'number' : 'text'}
1476
- value={String(addDraft.config[key] ?? '')}
1477
- onChange={(e) => setAddDraft((d) => ({
1478
- ...d,
1479
- config: { ...d.config, [key]: e.target.value },
1480
- }))}
1481
- placeholder={(prop as any)?.['x-neuralis']?.ui?.placeholder ?? (prop as any)?.description ?? key}
1482
- className={inputCls}
1483
- />
1484
- </div>
1485
- ))}
1486
- {/* Directory browser (non-Docker only) */}
1487
- {showContainerPathChrome && !isDocker && browseOpen && (
1488
- <div className="rounded-md bg-white/[0.03] border border-white/[0.1] overflow-hidden">
1489
- <div className="flex items-center gap-1.5 px-2 py-1.5 bg-white/[0.02] border-b border-white/[0.06]">
1490
- <FolderOpen className="h-3 w-3 text-cyan-400 shrink-0" />
1491
- <span className="text-[9px] font-mono text-white/60 truncate flex-1">{browsePath}</span>
1492
- <button type="button" onClick={() => setBrowseOpen(false)}
1493
- className="text-[9px] text-white/40 hover:text-white/70 px-1">Close</button>
1494
- </div>
1495
- {browseError && (
1496
- <div className="px-2 py-1 text-[9px] text-red-300/80">{browseError}</div>
1497
- )}
1498
- {browseLoading ? (
1499
- <div className="flex items-center justify-center py-3">
1500
- <Loader2 className="h-3 w-3 animate-spin text-white/30" />
1501
- </div>
1502
- ) : (
1503
- <div className="max-h-[180px] overflow-y-auto">
1504
- {browsePath !== '/' && (
1505
- <button
1506
- type="button"
1507
- onClick={() => {
1508
- const parent = browsePath.replace(/[/\\][^/\\]+$/, '') || '/';
1509
- void openBrowser(parent);
1510
- }}
1511
- className="w-full flex items-center gap-1.5 px-2 py-1 text-left hover:bg-white/[0.04] transition"
1512
- >
1513
- <ChevronUp className="h-3 w-3 text-white/30" />
1514
- <span className="text-[10px] text-white/50">..</span>
1515
- </button>
1516
- )}
1517
- {browseEntries.length === 0 && !browseLoading && (
1518
- <div className="px-2 py-2 text-[9px] text-white/30 italic">No subdirectories</div>
1519
- )}
1520
- {browseEntries.map((entry) => (
1521
- <div key={entry.path} className="flex items-center gap-0.5 hover:bg-white/[0.04] transition">
1522
- <button
1523
- type="button"
1524
- onClick={() => void openBrowser(entry.path)}
1525
- className="flex items-center gap-1.5 px-2 py-1 flex-1 text-left min-w-0"
1526
- >
1527
- <Folder className="h-3 w-3 text-cyan-400/70 shrink-0" />
1528
- <span className="text-[10px] text-white/75 truncate">{entry.name}</span>
1529
- </button>
1530
- <button
1531
- type="button"
1532
- onClick={() => selectBrowsePath(entry.path)}
1533
- className="px-2 py-1 text-[8px] font-medium text-cyan-300/70 hover:text-cyan-200 shrink-0"
1534
- >
1535
- Select
1536
- </button>
1537
- </div>
1538
- ))}
1539
- <button
1540
- type="button"
1541
- onClick={() => selectBrowsePath(browsePath)}
1542
- className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left bg-cyan-500/5 hover:bg-cyan-500/10 border-t border-white/[0.06] transition"
1543
- >
1544
- <CheckCircle2 className="h-3 w-3 text-cyan-400 shrink-0" />
1545
- <span className="text-[10px] font-medium text-cyan-300/80">Select this folder</span>
1546
- <span className="text-[9px] text-white/30 font-mono truncate ml-auto">{browsePath}</span>
1547
- </button>
1548
- </div>
1549
- )}
1550
- </div>
1551
- )}
1552
- </div>
1553
1550
  <div className="flex items-center gap-2">
1554
1551
  <span className="text-[9px] font-semibold uppercase tracking-wider text-white/35 w-16 shrink-0">Access</span>
1555
1552
  {([
1556
1553
  { key: 'read' as const, label: 'Read', icon: Eye },
1557
1554
  { key: 'write' as const, label: 'Write', icon: Pencil },
1558
1555
  { key: 'exec' as const, label: 'Exec', icon: Terminal },
1559
- ]).map(({ key, label, icon: PIcon }) => (
1560
- <label key={key} className={cn(
1556
+ ])
1557
+ // Exec is a SOURCE policy bit: offered wherever a shell can run in
1558
+ // the kind's sources at all (`shell`, the server's answer). Who may
1559
+ // then run one is the caller's own feature (`execRequires`, the
1560
+ // tooltip) — never a reason to hide the bit from the person setting
1561
+ // it for others.
1562
+ .filter(({ key }) => key !== 'exec' || selectedKind?.shell !== false)
1563
+ .map(({ key, label, icon: PIcon }) => (
1564
+ <label key={key} title={key === 'exec' && selectedKind?.execRequires?.length ? `Running programs here needs ${selectedKind.execRequires.join(' or ')}` : undefined} className={cn(
1561
1565
  'inline-flex items-center gap-1 rounded px-2 py-1 text-[9px] cursor-pointer transition',
1562
1566
  addDraft[key] ? 'bg-white/[0.06] text-white/85' : 'bg-white/[0.02] text-white/40',
1563
1567
  )}>
@@ -1568,20 +1572,25 @@ export function SourcesPanel(props: Props) {
1568
1572
  </label>
1569
1573
  ))}
1570
1574
  </div>
1571
- <div className="space-y-1">
1572
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Description <span className="text-white/20 normal-case font-normal">(optional — shown to the agent)</span></div>
1573
- {/* The connector's own text as the PLACEHOLDER, not as a value:
1574
- leaving it blank resolves through the same chain at read time,
1575
- so the agent still gets the package's wording — and it keeps
1576
- tracking the package instead of freezing a copy here. */}
1577
- <input value={addDraft.description} onChange={(e) => setAddDraft((d) => ({ ...d, description: e.target.value }))} className={inputCls} placeholder={selectedKind?.description ?? 'What this source is for…'} maxLength={200} />
1578
- </div>
1579
1575
  {selectedKindSupportsSync && (
1580
1576
  <div className="flex items-center gap-2">
1581
1577
  <label className="inline-flex items-center gap-1.5 text-[10px] text-white/80 cursor-pointer">
1582
1578
  <input type="checkbox" checked={addDraft.syncEnabled} onChange={(e) => setAddDraft((d) => ({ ...d, syncEnabled: e.target.checked }))} className="accent-emerald-400 h-3 w-3" />
1583
1579
  Enable sync
1584
1580
  </label>
1581
+ {addDraft.syncEnabled && (
1582
+ <select
1583
+ value={addDraft.trigger.startsWith('interval:') ? 'manual' : addDraft.trigger}
1584
+ onChange={(e) => setAddDraft((d) => ({ ...d, trigger: e.target.value as SyncTriggerValue }))}
1585
+ className={cn(selectCls, 'w-auto')}
1586
+ title="When the folder syncs (a schedule is set on the source after it is added)"
1587
+ >
1588
+ {addDraft.trigger.startsWith('interval:') && <option value={addDraft.trigger}>On schedule ({addDraft.trigger.slice('interval:'.length)})</option>}
1589
+ {TRIGGER_OPTIONS.filter((o) => o.value !== 'interval').map((o) => (
1590
+ <option key={o.value} value={o.value}>{o.label}</option>
1591
+ ))}
1592
+ </select>
1593
+ )}
1585
1594
  </div>
1586
1595
  )}
1587
1596
  {selectedKindSupportsSync && addDraft.syncEnabled && (
@@ -1597,22 +1606,48 @@ export function SourcesPanel(props: Props) {
1597
1606
  <div className="text-[8px] text-white/25 italic">Non-empty: only matching files sync. Excludes always win — an excluded path can never be re-included. This narrows the INDEX, not what the agent can read: the source root does that.</div>
1598
1607
  </div>
1599
1608
  )}
1600
- {addKind === 'local' && isOwnerEffective && (
1601
- <div className="space-y-1">
1602
- <label className="inline-flex items-start gap-1.5 text-[10px] text-white/80 cursor-pointer">
1609
+ <details className="group rounded-md bg-white/[0.02] px-2 py-1">
1610
+ <summary className="cursor-pointer select-none text-[9px] font-semibold uppercase tracking-wider text-white/35 hover:text-white/60">
1611
+ Advanced <span className="normal-case font-normal text-white/25">· {addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</span>
1612
+ </summary>
1613
+ <div className="mt-2 space-y-2 pb-1">
1614
+ <div className="space-y-1">
1615
+ <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">URI scheme</div>
1603
1616
  <input
1604
- type="checkbox"
1605
- checked={addDraft.recognizesPackages}
1606
- onChange={(e) => setAddDraft((d) => ({ ...d, recognizesPackages: e.target.checked }))}
1607
- className="accent-emerald-400 h-3 w-3 mt-0.5"
1617
+ type="text"
1618
+ value={addDraft.key}
1619
+ onChange={(e) => setAddDraft((d) => ({ ...d, key: e.target.value }))}
1620
+ placeholder={computeSuggestedKey(addDraft.scope, addDraft.path)}
1621
+ className={inputCls}
1608
1622
  />
1609
- <span>
1610
- Recognize packages
1611
- <span className="block text-[9px] text-white/35 normal-case font-normal">
1612
- Load packages found in this folder (in addition to <code>_packages/</code>) as soon as the source is added. Advertised only within this source&apos;s scope.
1613
- </span>
1614
- </span>
1615
- </label>
1623
+ <div className="text-[8px] text-white/25 font-mono">{addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</div>
1624
+ </div>
1625
+ {formFields.advanced.map(renderConfigField)}
1626
+ <div className="space-y-1">
1627
+ <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Description <span className="text-white/20 normal-case font-normal">(optional — shown to the agent)</span></div>
1628
+ {/* The connector's own text as the PLACEHOLDER, not as a value:
1629
+ leaving it blank resolves through the same chain at read time,
1630
+ so the agent still gets the package's wording — and it keeps
1631
+ tracking the package instead of freezing a copy here. */}
1632
+ <input value={addDraft.description} onChange={(e) => setAddDraft((d) => ({ ...d, description: e.target.value }))} className={inputCls} placeholder={selectedKind?.description ?? 'What this source is for…'} maxLength={200} />
1633
+ </div>
1634
+ {addKind === HOST_KIND && isOwnerEffective && (
1635
+ <PackageFoldersField
1636
+ value={addDraft.packageFolders}
1637
+ onChange={(packageFolders) => setAddDraft((d) => ({ ...d, packageFolders }))}
1638
+ note="as soon as the source is added"
1639
+ />
1640
+ )}
1641
+ </div>
1642
+ </details>
1643
+ {chosenDevice && deviceGuard !== null && deviceGuard === addDraft.path && (
1644
+ <div className="rounded-md border border-amber-400/20 bg-amber-500/[0.04] px-2 py-1.5 space-y-1">
1645
+ <div className="text-[9px] text-amber-300/80">{DEVICE_FOLDER_NOT_ALLOWED} Ask there, or attach it now — it serves once the device allows it.</div>
1646
+ <DeviceFolderAsk key={deviceGuard} device={chosenDevice} projectId={projectId} agentId={agentId} path={deviceGuard} wantWrite={addDraft.write} />
1647
+ <button type="button" onClick={() => void handleAddSource(true)} disabled={addCreating}
1648
+ className="h-6 px-2.5 rounded-md bg-white/[0.04] border border-white/[0.1] text-[9px] font-semibold text-white/70 hover:bg-white/[0.08] transition disabled:opacity-35">
1649
+ Attach anyway — it serves once allowed
1650
+ </button>
1616
1651
  </div>
1617
1652
  )}
1618
1653
  <div className="flex items-center gap-1.5 pt-1">
@@ -1637,7 +1672,7 @@ export function SourcesPanel(props: Props) {
1637
1672
  {grouped.map(([origin, rows]) => (
1638
1673
  <div key={origin}>
1639
1674
  {/* Origin group header — small, indented a touch LEFT of its rows,
1640
- replaces the per-row "first-party" badge. */}
1675
+ replaces the per-row "native" badge. */}
1641
1676
  <div className="-ml-0.5 mb-0.5 flex items-center gap-1.5 px-1">
1642
1677
  <Package className="h-3 w-3 shrink-0 text-white/30" />
1643
1678
  <span className="truncate font-mono text-[10px] font-medium text-white/40">{origin}</span>
@@ -1669,13 +1704,14 @@ export function SourcesPanel(props: Props) {
1669
1704
  const dirty = editable && isDirty(config, draft);
1670
1705
  const tp = parseTrigger(syncDraft.trigger);
1671
1706
  const sections = subSections[config.source] ?? {};
1707
+ const watchBadge = sourceWatchBadge(config.watch, config.live_reason);
1672
1708
 
1673
1709
  return (
1674
1710
  <div key={fileKey(sourceRootUri(config.source))} className="rounded-sm">
1675
1711
  {/* Accordion header: selection and enabled actions are siblings. */}
1676
1712
  <div className="w-full flex items-center gap-2 py-2 text-left group hover:bg-white/[0.02] rounded-md px-1 transition">
1677
1713
  <button type="button" onClick={() => toggleSource(config.source)} aria-expanded={isOpen}
1678
- title={disconnected ? sourceLiveReasonLabel(config.live_reason) : undefined}
1714
+ title={disconnected ? sourceLiveReasonLabel(config.live_reason, { kind: config.connection.kind, notReady: config.live_not_ready }) : undefined}
1679
1715
  className="min-w-0 flex-1 flex items-center gap-2 text-left">
1680
1716
  {/* Git indicator (G2.1) — a GitBranch overlay on a git-repo
1681
1717
  source's icon, ORTHOGONAL to the sync tone (visualTone still
@@ -1689,6 +1725,17 @@ export function SourcesPanel(props: Props) {
1689
1725
  <SourceIcon className={cn('h-4 w-4 shrink-0', visualTone.iconClassName)} />
1690
1726
  )}
1691
1727
  <span className={cn('min-w-0 flex-1 truncate text-[12.5px] font-semibold', dimmed ? 'text-white/45' : 'text-white/90')}>{config.source}</span>
1728
+ {watchBadge ? (
1729
+ <span
1730
+ className={cn(
1731
+ 'shrink-0 rounded px-1 text-[9px] uppercase tracking-wider',
1732
+ watchBadge.tone === 'pending' ? 'text-white/55 bg-white/[0.06]' : 'text-amber-200/80 bg-amber-500/10',
1733
+ )}
1734
+ title={watchBadge.title}
1735
+ >
1736
+ {watchBadge.label}
1737
+ </span>
1738
+ ) : null}
1692
1739
  {config.git?.isRepo && config.git.branch ? (
1693
1740
  <span
1694
1741
  className="inline-flex max-w-[96px] shrink-0 items-center gap-0.5 text-[10px] text-violet-300/70"
@@ -1792,10 +1839,16 @@ export function SourcesPanel(props: Props) {
1792
1839
  </div>
1793
1840
 
1794
1841
  {configSupportsSync && !config.sync?.lastSync ? (
1795
- <div className="rounded-md border border-sky-300/15 bg-sky-500/5 px-2 py-1.5 text-[10px] text-sky-100/70">
1796
- Never synced — run a sync to index this source and load its packages
1797
- (skills, rules, instructions) for agents.
1798
- </div>
1842
+ config.sync?.lastSyncResult?.error ? (
1843
+ <div className="rounded-md border border-rose-300/15 bg-rose-500/5 px-2 py-1.5 text-[10px] text-rose-100/70">
1844
+ {syncErrorLabel(config.sync.lastSyncResult.error)}
1845
+ </div>
1846
+ ) : (
1847
+ <div className="rounded-md border border-sky-300/15 bg-sky-500/5 px-2 py-1.5 text-[10px] text-sky-100/70">
1848
+ Never synced — run a sync to index this source and load its packages
1849
+ (skills, rules, instructions) for agents.
1850
+ </div>
1851
+ )
1799
1852
  ) : null}
1800
1853
 
1801
1854
  {config.observed ? (
@@ -1858,27 +1911,15 @@ export function SourcesPanel(props: Props) {
1858
1911
  )}
1859
1912
  </div>
1860
1913
 
1861
- {/* R2b — Recognize packages toggle. Owner/admin-effective +
1862
- local-kind only (server re-gates). Makes this source a package
1863
- source too; advertised only within its own scope. */}
1864
- {editable && isOwnerEffective && (meta?.kind ?? config.connection.kind) === 'local' && (
1865
- <div className="pl-0">
1866
- <label className="flex items-start gap-1.5 py-1 cursor-pointer">
1867
- <Package className="h-3 w-3 text-cyan-400 shrink-0 mt-0.5" />
1868
- <input
1869
- type="checkbox"
1870
- checked={draft.recognizesPackages ?? false}
1871
- onChange={(e) => updateDraft(config.source, (d) => ({ ...d, recognizesPackages: e.target.checked }))}
1872
- className="accent-cyan-400 h-3 w-3 mt-0.5"
1873
- />
1874
- <span className="text-[10px] text-white/70 leading-tight">
1875
- Recognize packages
1876
- <span className="block text-[9px] text-white/35 normal-case font-normal">
1877
- Load packages found in this folder (in addition to <code>_packages/</code>) — applied on save; advertised only within this source&apos;s scope.
1878
- </span>
1879
- </span>
1880
- </label>
1881
- </div>
1914
+ {/* R2b — package folders. Owner/admin-effective + folder kind
1915
+ only (server re-gates). Makes those folders package sources
1916
+ too; advertised only within the source's own scope. */}
1917
+ {editable && isOwnerEffective && (meta?.kind ?? config.connection.kind) === HOST_KIND && (
1918
+ <PackageFoldersField
1919
+ value={formatPackageFolders(draft.packageFolders)}
1920
+ onChange={(text) => updateDraft(config.source, (d) => ({ ...d, packageFolders: parsePackageFolders(text) }))}
1921
+ note="applied on save"
1922
+ />
1882
1923
  )}
1883
1924
 
1884
1925
  {/* Permissions section — admin-style compact editor.
@@ -1887,7 +1928,7 @@ export function SourcesPanel(props: Props) {
1887
1928
  adjacent SkillLauncher hands the same uri-policy edit to
1888
1929
  the agent. Its visibility is DERIVED from the host's
1889
1930
  per-caller skill catalog (the `manage-uri-policy` skill
1890
- declares `drive.policy` itself) — never re-stated here. */}
1931
+ declares `sources.policy` itself) — never re-stated here. */}
1891
1932
  <div>
1892
1933
  <div className="flex items-center gap-1.5 py-1">
1893
1934
  <button type="button" onClick={() => toggleSubSection(config.source, 'perms')}
@@ -2049,7 +2090,10 @@ export function SourcesPanel(props: Props) {
2049
2090
  <span>Last sync: +{config.sync.lastSyncResult.filesAdded} -{config.sync.lastSyncResult.filesRemoved} ~{config.sync.lastSyncResult.filesUpdated}</span>
2050
2091
  <span>({(config.sync.lastSyncResult.durationMs / 1000).toFixed(1)}s)</span>
2051
2092
  {config.sync.lastSyncResult.error && (
2052
- <span className="text-rose-300/80 truncate max-w-[200px]" title={config.sync.lastSyncResult.error}>err</span>
2093
+ <span className="text-rose-300/80 truncate max-w-[200px]" title={syncErrorLabel(config.sync.lastSyncResult.error)}>err</span>
2094
+ )}
2095
+ {config.sync.lastSyncResult.error === 'root_empty' && (
2096
+ <button type="button" className="text-rose-200/80 underline" onClick={() => void handleSync(config.source, true)}>Sync anyway</button>
2053
2097
  )}
2054
2098
  </div>
2055
2099
  )}
@@ -2193,9 +2237,7 @@ export function SourcesPanel(props: Props) {
2193
2237
  <SourceDeleteConfirm
2194
2238
  source={config.source}
2195
2239
  vectorCount={typeof config.indexed_count === 'number' ? config.indexed_count : null}
2196
- isDockerMount={Boolean(
2197
- (config.connection?.config as { hostRoot?: unknown } | undefined)?.hostRoot,
2198
- )}
2240
+ isDockerMount={isDockerBind(config)}
2199
2241
  busy={deleting}
2200
2242
  onCancel={() => setDeleteConfirmFor(null)}
2201
2243
  onConfirm={(opts) => void handleDelete(config.source, opts)}
@@ -2355,3 +2397,121 @@ export function SourcesPanel(props: Props) {
2355
2397
  </div>
2356
2398
  );
2357
2399
  }
2400
+
2401
+ // ---------------------------------------------------------------------------
2402
+ // `select` field: options answered by a route in the DECLARING package
2403
+ // ---------------------------------------------------------------------------
2404
+
2405
+ /**
2406
+ * A dimmed option (an offline device): a muted text colour mixed OPAQUE into
2407
+ * the option's own surface, inline — the global `select option` rule outranks
2408
+ * any utility class, while an inline colour wins. Never `opacity`: the muted
2409
+ * colour is computed opaque against the same surface the option paints.
2410
+ */
2411
+ const DIMMED_OPTION_STYLE = {
2412
+ color: 'color-mix(in srgb, var(--control-text) 48%, rgb(var(--w-composer-bg-rgb, 30, 32, 38)))',
2413
+ } as const;
2414
+
2415
+ /**
2416
+ * A config field declared `x-neuralis.ui.control: "select"`. ONE request per
2417
+ * mount (and per project/agent change) to the declaring package's
2418
+ * `optionsRoute`, through that package's own route client — never this
2419
+ * package's — and ONE quiet re-read per `revision` bump (a burst of `devices`
2420
+ * frames): the shown list stays until the new answer replaces it. A failed
2421
+ * load is SHOWN, never an empty list; the list is UX only, the declaring
2422
+ * package re-checks the value when the source is created.
2423
+ */
2424
+ function OptionsRouteSelect({
2425
+ route,
2426
+ emptyLabel,
2427
+ revision,
2428
+ projectId,
2429
+ agentId,
2430
+ value,
2431
+ onChange,
2432
+ }: {
2433
+ route: OptionsRoute;
2434
+ /** An optional field's empty choice (`fieldControl`), selectable — absent ⇒ a disabled "Choose…". */
2435
+ emptyLabel?: string;
2436
+ revision: number;
2437
+ projectId?: string | null;
2438
+ agentId: string;
2439
+ value: string;
2440
+ /** The picked value and its row (`undefined` for the empty choice). */
2441
+ onChange: (value: string, option?: SelectOption) => void;
2442
+ }) {
2443
+ const [options, setOptions] = useState<SelectOption[] | null>(null);
2444
+ const [loadError, setLoadError] = useState<string | null>(null);
2445
+ // The route is re-derived on every render; the effect keys on its VALUE.
2446
+ const { slug, path } = route;
2447
+ const query = new URLSearchParams(route.params).toString();
2448
+ // A new route/project/agent starts from "Loading…"; a revision bump does not.
2449
+ const scopeKey = `${slug}\n${path}\n${query}\n${projectId ?? ''}\n${agentId}`;
2450
+ const loadedScope = useRef<string | null>(null);
2451
+ useEffect(() => {
2452
+ let live = true;
2453
+ if (loadedScope.current !== scopeKey) {
2454
+ loadedScope.current = scopeKey;
2455
+ setOptions(null);
2456
+ }
2457
+ setLoadError(null);
2458
+ const api = createPackageApi({
2459
+ slug,
2460
+ getProjectId: () => projectId ?? null,
2461
+ getAgentId: () => agentId || null,
2462
+ });
2463
+ api.get<unknown>(path, Object.fromEntries(new URLSearchParams(query))).then(
2464
+ (answer) => { if (live) setOptions(selectOptionsFrom(answer)); },
2465
+ (err: unknown) => { if (live) setLoadError(err instanceof Error ? err.message : 'Could not load the options'); },
2466
+ );
2467
+ return () => { live = false; };
2468
+ }, [slug, path, query, projectId, agentId, scopeKey, revision]);
2469
+
2470
+ if (loadError) return <div className="text-[9px] text-red-300/80">{loadError}</div>;
2471
+ if (options === null) return <div className="text-[9px] text-white/30 animate-pulse">Loading…</div>;
2472
+ if (options.length === 0 && !emptyLabel) return <div className="text-[9px] text-white/40 italic">Nothing to choose from.</div>;
2473
+ return (
2474
+ <select value={value} onChange={(e) => onChange(e.target.value, options.find((o) => o.value === e.target.value))} className={selectCls}>
2475
+ {emptyLabel ? <option value="">{emptyLabel}</option> : <option value="" disabled>Choose…</option>}
2476
+ {options.map((o) => (
2477
+ <option key={o.value} value={o.value} style={o.dimmed ? DIMMED_OPTION_STYLE : undefined}>
2478
+ {o.label}
2479
+ </option>
2480
+ ))}
2481
+ </select>
2482
+ );
2483
+ }
2484
+
2485
+ /**
2486
+ * The package-folders field both forms show — one folder per line, relative to
2487
+ * the source root, `.` for the whole folder. It keeps its OWN text (a list
2488
+ * round-trip would swallow the newline being typed) and reports every edit;
2489
+ * the caller parses (`parsePackageFolders`). The list is UX: the server
2490
+ * re-validates the folders and re-gates the owner/admin write.
2491
+ */
2492
+ function PackageFoldersField({ value, onChange, note }: { value: string; onChange: (text: string) => void; note: string }) {
2493
+ const [text, setText] = useState(value);
2494
+ // An external change (a reset, a saved answer) replaces the text; an echo of
2495
+ // this field's own edit does not.
2496
+ useEffect(() => {
2497
+ setText((current) => (parsePackageFolders(current).join('\n') === parsePackageFolders(value).join('\n') ? current : value));
2498
+ }, [value]);
2499
+ return (
2500
+ <div className="space-y-1">
2501
+ <div className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-wider text-white/35">
2502
+ <Package className="h-3 w-3 text-cyan-400 shrink-0" />
2503
+ Package folders
2504
+ </div>
2505
+ <textarea
2506
+ value={text}
2507
+ onChange={(e) => { setText(e.target.value); onChange(e.target.value); }}
2508
+ rows={2}
2509
+ className={cn(textareaCls, 'font-mono')}
2510
+ placeholder={'# empty = none\n.\ntools/pkgs'}
2511
+ />
2512
+ <div className="text-[8px] text-white/25 italic">
2513
+ Load the packages found in these folders (in addition to <code>_packages/</code>), {note}; <code>.</code> is the whole folder. Advertised only within this source&apos;s scope.
2514
+ </div>
2515
+ </div>
2516
+ );
2517
+ }