@neuralis/brain-core 0.3.2 → 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 (311) hide show
  1. package/README.md +79 -1409
  2. package/app/README.md +1 -1
  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 +17 -2
  8. package/app/filesystem/api/README.md +3 -3
  9. package/app/filesystem/api/index.ts +35 -11
  10. package/app/filesystem/api/projectPackages.ts +13 -102
  11. package/app/filesystem/domain/types.ts +30 -10
  12. package/app/filesystem/hooks/README.md +3 -3
  13. package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
  14. package/app/filesystem/hooks/useFilesystemSSE.ts +1 -1
  15. package/app/filesystem/host/README.md +5 -3
  16. package/app/filesystem/host/buildFilesystemHostValue.ts +19 -0
  17. package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
  18. package/app/filesystem/lib/sourceKindForm.ts +118 -39
  19. package/app/filesystem/lib/treeVisualState.tsx +98 -4
  20. package/app/filesystem/store/README.md +1 -0
  21. package/app/filesystem/store/filesystemStore.ts +86 -5
  22. package/app/filesystem/store/gitSlice.ts +5 -0
  23. package/app/filesystem/store/helpers.ts +17 -0
  24. package/app/filesystem/store/listSlice.ts +8 -0
  25. package/app/filesystem/ui/CodeEditor.tsx +18 -3
  26. package/app/filesystem/ui/FileEditor.tsx +15 -10
  27. package/app/filesystem/ui/FileTree.tsx +41 -16
  28. package/app/filesystem/ui/FolderPicker.tsx +251 -0
  29. package/app/filesystem/ui/GitControlPanel.tsx +5 -0
  30. package/app/filesystem/ui/PackagesPanel.tsx +52 -374
  31. package/app/filesystem/ui/PolicyEditor.tsx +109 -24
  32. package/app/filesystem/ui/README.md +17 -11
  33. package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
  34. package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
  35. package/app/filesystem/ui/SourcesPanel.tsx +420 -360
  36. package/app/filesystem/ui/Toolbar.tsx +1 -2
  37. package/app/shared/filesystem.png +0 -0
  38. package/dist/app/host.js +57 -49
  39. package/dist/scripts/sourceRootPairV1.d.ts +47 -0
  40. package/dist/scripts/sourceRootPairV1.d.ts.map +1 -0
  41. package/dist/scripts/sourceRootPairV1.js +147 -0
  42. package/dist/scripts/sourceRootPairV1.js.map +1 -0
  43. package/dist/scripts/sourceToFolderV1.d.ts +56 -0
  44. package/dist/scripts/sourceToFolderV1.d.ts.map +1 -0
  45. package/dist/scripts/sourceToFolderV1.js +152 -0
  46. package/dist/scripts/sourceToFolderV1.js.map +1 -0
  47. package/dist/scripts/sourceToHostV1.d.ts +56 -0
  48. package/dist/scripts/sourceToHostV1.d.ts.map +1 -0
  49. package/dist/scripts/sourceToHostV1.js +132 -0
  50. package/dist/scripts/sourceToHostV1.js.map +1 -0
  51. package/dist/src/connectors/AppSourceConfigStore.d.ts +17 -3
  52. package/dist/src/connectors/AppSourceConfigStore.d.ts.map +1 -1
  53. package/dist/src/connectors/AppSourceConfigStore.js +46 -6
  54. package/dist/src/connectors/AppSourceConfigStore.js.map +1 -1
  55. package/dist/src/connectors/HostConnector.d.ts +201 -0
  56. package/dist/src/connectors/HostConnector.d.ts.map +1 -0
  57. package/dist/src/connectors/HostConnector.js +903 -0
  58. package/dist/src/connectors/HostConnector.js.map +1 -0
  59. package/dist/src/connectors/inProcessTransport.d.ts +131 -0
  60. package/dist/src/connectors/inProcessTransport.d.ts.map +1 -0
  61. package/dist/src/connectors/inProcessTransport.js +447 -0
  62. package/dist/src/connectors/inProcessTransport.js.map +1 -0
  63. package/dist/src/domain/access/callerPermissions.d.ts +29 -0
  64. package/dist/src/domain/access/callerPermissions.d.ts.map +1 -1
  65. package/dist/src/domain/access/callerPermissions.js +85 -5
  66. package/dist/src/domain/access/callerPermissions.js.map +1 -1
  67. package/dist/src/domain/access/nativeLocalFence.d.ts +65 -0
  68. package/dist/src/domain/access/nativeLocalFence.d.ts.map +1 -0
  69. package/dist/src/domain/access/nativeLocalFence.js +171 -0
  70. package/dist/src/domain/access/nativeLocalFence.js.map +1 -0
  71. package/dist/src/domain/access/uriPermission.js +3 -3
  72. package/dist/src/domain/access/uriPermission.js.map +1 -1
  73. package/dist/src/domain/byteRange.d.ts +15 -0
  74. package/dist/src/domain/byteRange.d.ts.map +1 -0
  75. package/dist/src/domain/byteRange.js +30 -0
  76. package/dist/src/domain/byteRange.js.map +1 -0
  77. package/dist/src/domain/defaultPermissions.d.ts.map +1 -1
  78. package/dist/src/domain/defaultPermissions.js +29 -0
  79. package/dist/src/domain/defaultPermissions.js.map +1 -1
  80. package/dist/src/domain/mime.d.ts +8 -16
  81. package/dist/src/domain/mime.d.ts.map +1 -1
  82. package/dist/src/domain/mime.js +10 -99
  83. package/dist/src/domain/mime.js.map +1 -1
  84. package/dist/src/domain/paths.d.ts.map +1 -1
  85. package/dist/src/domain/paths.js +5 -3
  86. package/dist/src/domain/paths.js.map +1 -1
  87. package/dist/src/domain/sourceConfig.d.ts +52 -19
  88. package/dist/src/domain/sourceConfig.d.ts.map +1 -1
  89. package/dist/src/domain/sourceConfig.js +103 -19
  90. package/dist/src/domain/sourceConfig.js.map +1 -1
  91. package/dist/src/domain/sourcePackages.d.ts +8 -8
  92. package/dist/src/domain/sourcePackages.d.ts.map +1 -1
  93. package/dist/src/domain/sourceRootPair.d.ts +88 -0
  94. package/dist/src/domain/sourceRootPair.d.ts.map +1 -0
  95. package/dist/src/domain/sourceRootPair.js +108 -0
  96. package/dist/src/domain/sourceRootPair.js.map +1 -0
  97. package/dist/src/domain/syncTypes.d.ts +8 -1
  98. package/dist/src/domain/syncTypes.d.ts.map +1 -1
  99. package/dist/src/domain/syncTypes.js +1 -0
  100. package/dist/src/domain/syncTypes.js.map +1 -1
  101. package/dist/src/domain/types.d.ts +24 -1
  102. package/dist/src/domain/types.d.ts.map +1 -1
  103. package/dist/src/domain/uri.d.ts.map +1 -1
  104. package/dist/src/domain/uri.js +3 -2
  105. package/dist/src/domain/uri.js.map +1 -1
  106. package/dist/src/embeddings/clients/GeminiEmbedder.d.ts.map +1 -1
  107. package/dist/src/embeddings/clients/GeminiEmbedder.js +2 -5
  108. package/dist/src/embeddings/clients/GeminiEmbedder.js.map +1 -1
  109. package/dist/src/embeddings/clients/OpenAIEmbedder.d.ts.map +1 -1
  110. package/dist/src/embeddings/clients/OpenAIEmbedder.js +2 -3
  111. package/dist/src/embeddings/clients/OpenAIEmbedder.js.map +1 -1
  112. package/dist/src/embeddings/clients/QwenEmbedder.d.ts +2 -0
  113. package/dist/src/embeddings/clients/QwenEmbedder.d.ts.map +1 -1
  114. package/dist/src/embeddings/clients/QwenEmbedder.js +10 -2
  115. package/dist/src/embeddings/clients/QwenEmbedder.js.map +1 -1
  116. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts +2 -0
  117. package/dist/src/embeddings/clients/VoyageEmbedder.d.ts.map +1 -1
  118. package/dist/src/embeddings/clients/VoyageEmbedder.js +8 -1
  119. package/dist/src/embeddings/clients/VoyageEmbedder.js.map +1 -1
  120. package/dist/src/embeddings/factory.d.ts +5 -3
  121. package/dist/src/embeddings/factory.d.ts.map +1 -1
  122. package/dist/src/embeddings/factory.js +7 -3
  123. package/dist/src/embeddings/factory.js.map +1 -1
  124. package/dist/src/embeddings/models.d.ts +1 -1
  125. package/dist/src/embeddings/models.d.ts.map +1 -1
  126. package/dist/src/embeddings/retry.js +1 -1
  127. package/dist/src/embeddings/retry.js.map +1 -1
  128. package/dist/src/embeddings/types.d.ts +6 -0
  129. package/dist/src/embeddings/types.d.ts.map +1 -1
  130. package/dist/src/embeddings/types.js +11 -0
  131. package/dist/src/embeddings/types.js.map +1 -1
  132. package/dist/src/git/GitRunner.d.ts +110 -30
  133. package/dist/src/git/GitRunner.d.ts.map +1 -1
  134. package/dist/src/git/GitRunner.js +511 -199
  135. package/dist/src/git/GitRunner.js.map +1 -1
  136. package/dist/src/git/GitWatchRegistry.d.ts +41 -35
  137. package/dist/src/git/GitWatchRegistry.d.ts.map +1 -1
  138. package/dist/src/git/GitWatchRegistry.js +61 -79
  139. package/dist/src/git/GitWatchRegistry.js.map +1 -1
  140. package/dist/src/git/gitPlace.d.ts +61 -0
  141. package/dist/src/git/gitPlace.d.ts.map +1 -0
  142. package/dist/src/git/gitPlace.js +74 -0
  143. package/dist/src/git/gitPlace.js.map +1 -0
  144. package/dist/src/git/sourceGitControl.d.ts +31 -19
  145. package/dist/src/git/sourceGitControl.d.ts.map +1 -1
  146. package/dist/src/git/sourceGitControl.js +208 -92
  147. package/dist/src/git/sourceGitControl.js.map +1 -1
  148. package/dist/src/git/sourceGitStatus.js +1 -1
  149. package/dist/src/git/sourceGitStatus.js.map +1 -1
  150. package/dist/src/git/untrackedStats.js +1 -1
  151. package/dist/src/git/untrackedStats.js.map +1 -1
  152. package/dist/src/index.d.ts +2 -1
  153. package/dist/src/index.d.ts.map +1 -1
  154. package/dist/src/index.js +2 -1
  155. package/dist/src/index.js.map +1 -1
  156. package/dist/src/lifecycle.d.ts +8 -11
  157. package/dist/src/lifecycle.d.ts.map +1 -1
  158. package/dist/src/lifecycle.js +2 -1
  159. package/dist/src/lifecycle.js.map +1 -1
  160. package/dist/src/routes/git.d.ts.map +1 -1
  161. package/dist/src/routes/git.js +183 -54
  162. package/dist/src/routes/git.js.map +1 -1
  163. package/dist/src/routes/helpers/uriGuard.d.ts +1 -1
  164. package/dist/src/routes/helpers/uriGuard.d.ts.map +1 -1
  165. package/dist/src/routes/helpers/uriGuard.js +3 -2
  166. package/dist/src/routes/helpers/uriGuard.js.map +1 -1
  167. package/dist/src/routes/helpers.d.ts +10 -3
  168. package/dist/src/routes/helpers.d.ts.map +1 -1
  169. package/dist/src/routes/helpers.js +12 -5
  170. package/dist/src/routes/helpers.js.map +1 -1
  171. package/dist/src/routes/list.d.ts.map +1 -1
  172. package/dist/src/routes/list.js +2 -3
  173. package/dist/src/routes/list.js.map +1 -1
  174. package/dist/src/routes/policies.d.ts +6 -4
  175. package/dist/src/routes/policies.d.ts.map +1 -1
  176. package/dist/src/routes/policies.js +22 -16
  177. package/dist/src/routes/policies.js.map +1 -1
  178. package/dist/src/routes/raw.d.ts.map +1 -1
  179. package/dist/src/routes/raw.js +65 -11
  180. package/dist/src/routes/raw.js.map +1 -1
  181. package/dist/src/routes/sources.d.ts +60 -18
  182. package/dist/src/routes/sources.d.ts.map +1 -1
  183. package/dist/src/routes/sources.js +411 -299
  184. package/dist/src/routes/sources.js.map +1 -1
  185. package/dist/src/services/BridgeService.d.ts +15 -35
  186. package/dist/src/services/BridgeService.d.ts.map +1 -1
  187. package/dist/src/services/BridgeService.js +30 -88
  188. package/dist/src/services/BridgeService.js.map +1 -1
  189. package/dist/src/services/FilesystemService.d.ts +3 -2
  190. package/dist/src/services/FilesystemService.d.ts.map +1 -1
  191. package/dist/src/services/FilesystemService.js +110 -32
  192. package/dist/src/services/FilesystemService.js.map +1 -1
  193. package/dist/src/services/FilesystemUiService.d.ts +4 -1
  194. package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
  195. package/dist/src/services/FilesystemUiService.js +66 -25
  196. package/dist/src/services/FilesystemUiService.js.map +1 -1
  197. package/dist/src/services/SyncScheduler.d.ts +12 -4
  198. package/dist/src/services/SyncScheduler.d.ts.map +1 -1
  199. package/dist/src/services/SyncScheduler.js +37 -8
  200. package/dist/src/services/SyncScheduler.js.map +1 -1
  201. package/dist/src/services/baselineRepair.d.ts +13 -3
  202. package/dist/src/services/baselineRepair.d.ts.map +1 -1
  203. package/dist/src/services/baselineRepair.js +13 -3
  204. package/dist/src/services/baselineRepair.js.map +1 -1
  205. package/dist/src/services/declaredSources.d.ts +125 -42
  206. package/dist/src/services/declaredSources.d.ts.map +1 -1
  207. package/dist/src/services/declaredSources.js +117 -45
  208. package/dist/src/services/declaredSources.js.map +1 -1
  209. package/dist/src/store/dataFormats.d.ts +17 -2
  210. package/dist/src/store/dataFormats.d.ts.map +1 -1
  211. package/dist/src/store/dataFormats.js +18 -3
  212. package/dist/src/store/dataFormats.js.map +1 -1
  213. package/dist/src/sync/DeltaSyncRunner.d.ts.map +1 -1
  214. package/dist/src/sync/DeltaSyncRunner.js +144 -4
  215. package/dist/src/sync/DeltaSyncRunner.js.map +1 -1
  216. package/dist/src/sync/SyncJobQueue.d.ts +7 -2
  217. package/dist/src/sync/SyncJobQueue.d.ts.map +1 -1
  218. package/dist/src/sync/SyncJobQueue.js +16 -10
  219. package/dist/src/sync/SyncJobQueue.js.map +1 -1
  220. package/dist/src/sync/SyncWatchService.d.ts +21 -4
  221. package/dist/src/sync/SyncWatchService.d.ts.map +1 -1
  222. package/dist/src/sync/SyncWatchService.js +115 -10
  223. package/dist/src/sync/SyncWatchService.js.map +1 -1
  224. package/dist/src/sync/categoryInference.d.ts +1 -1
  225. package/dist/src/sync/categoryInference.js +1 -1
  226. package/dist/src/usecases/admitSourceRoot.d.ts +65 -0
  227. package/dist/src/usecases/admitSourceRoot.d.ts.map +1 -0
  228. package/dist/src/usecases/admitSourceRoot.js +85 -0
  229. package/dist/src/usecases/admitSourceRoot.js.map +1 -0
  230. package/dist/src/usecases/browseSourceFolders.d.ts +85 -0
  231. package/dist/src/usecases/browseSourceFolders.d.ts.map +1 -0
  232. package/dist/src/usecases/browseSourceFolders.js +210 -0
  233. package/dist/src/usecases/browseSourceFolders.js.map +1 -0
  234. package/dist/src/usecases/createDeclaredSources.d.ts +13 -2
  235. package/dist/src/usecases/createDeclaredSources.d.ts.map +1 -1
  236. package/dist/src/usecases/createDeclaredSources.js +39 -0
  237. package/dist/src/usecases/createDeclaredSources.js.map +1 -1
  238. package/dist/src/usecases/list.d.ts.map +1 -1
  239. package/dist/src/usecases/list.js +83 -26
  240. package/dist/src/usecases/list.js.map +1 -1
  241. package/dist/src/usecases/listAttachableDevices.d.ts +13 -0
  242. package/dist/src/usecases/listAttachableDevices.d.ts.map +1 -0
  243. package/dist/src/usecases/listAttachableDevices.js +10 -0
  244. package/dist/src/usecases/listAttachableDevices.js.map +1 -0
  245. package/dist/src/usecases/listSourcePackages.d.ts.map +1 -1
  246. package/dist/src/usecases/listSourcePackages.js +2 -5
  247. package/dist/src/usecases/listSourcePackages.js.map +1 -1
  248. package/dist/src/usecases/purgeExcludedPoints.d.ts.map +1 -1
  249. package/dist/src/usecases/purgeExcludedPoints.js +3 -2
  250. package/dist/src/usecases/purgeExcludedPoints.js.map +1 -1
  251. package/dist/src/usecases/readFiles.d.ts.map +1 -1
  252. package/dist/src/usecases/readFiles.js +15 -0
  253. package/dist/src/usecases/readFiles.js.map +1 -1
  254. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts +1 -1
  255. package/dist/src/usecases/reconcileSourcePackageRoots.d.ts.map +1 -1
  256. package/dist/src/usecases/reconcileSourcePackageRoots.js +2 -1
  257. package/dist/src/usecases/reconcileSourcePackageRoots.js.map +1 -1
  258. package/dist/src/usecases/revokePrincipal.d.ts +7 -5
  259. package/dist/src/usecases/revokePrincipal.d.ts.map +1 -1
  260. package/dist/src/usecases/revokePrincipal.js +29 -7
  261. package/dist/src/usecases/revokePrincipal.js.map +1 -1
  262. package/dist/src/usecases/scheduleSourceSync.d.ts +7 -0
  263. package/dist/src/usecases/scheduleSourceSync.d.ts.map +1 -1
  264. package/dist/src/usecases/scheduleSourceSync.js +6 -5
  265. package/dist/src/usecases/scheduleSourceSync.js.map +1 -1
  266. package/dist/src/usecases/triggerSourceSync.d.ts +46 -0
  267. package/dist/src/usecases/triggerSourceSync.d.ts.map +1 -0
  268. package/dist/src/usecases/triggerSourceSync.js +75 -0
  269. package/dist/src/usecases/triggerSourceSync.js.map +1 -0
  270. package/dist/src/usecases/uploadBinary.d.ts +2 -1
  271. package/dist/src/usecases/uploadBinary.d.ts.map +1 -1
  272. package/dist/src/usecases/uploadBinary.js +2 -2
  273. package/dist/src/usecases/uploadBinary.js.map +1 -1
  274. package/dist/src/usecases/vector/endpointGuard.d.ts +1 -1
  275. package/dist/src/usecases/vector/endpointGuard.js +1 -1
  276. package/dist/src/vector/qdrantClient.d.ts +1 -1
  277. package/dist/src/vector/qdrantClient.js +1 -1
  278. package/dist/src/wiring.d.ts +3 -0
  279. package/dist/src/wiring.d.ts.map +1 -1
  280. package/dist/src/wiring.js +72 -6
  281. package/dist/src/wiring.js.map +1 -1
  282. package/package.json +145 -54
  283. package/skills/connect-business-data/SKILL.md +2 -2
  284. package/skills/create-package/SKILL.md +18 -18
  285. package/skills/create-package/references/config-and-credentials.md +16 -13
  286. package/skills/create-package/references/connectors-and-uri-policies.md +36 -27
  287. package/skills/create-package/references/manifest-and-discovery.md +12 -12
  288. package/skills/create-package/references/runtime-and-build.md +18 -18
  289. package/skills/create-package/references/skills-and-context.md +35 -19
  290. package/skills/create-package/references/surfaces-and-design.md +16 -16
  291. package/skills/create-package/references/team-and-workflows.md +2 -2
  292. package/skills/create-package/references/test-and-ship.md +17 -17
  293. package/skills/create-package/references/tools-and-schemas.md +5 -5
  294. package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +78 -64
  295. package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
  296. package/skills/git/SKILL.md +31 -29
  297. package/skills/manage-files/SKILL.md +11 -14
  298. package/skills/manage-sources/SKILL.md +85 -45
  299. package/skills/manage-uri-policy/SKILL.md +9 -6
  300. package/skills/sync/SKILL.md +1 -1
  301. package/dist/src/connectors/LocalDiskConnector.d.ts +0 -171
  302. package/dist/src/connectors/LocalDiskConnector.d.ts.map +0 -1
  303. package/dist/src/connectors/LocalDiskConnector.js +0 -1233
  304. package/dist/src/connectors/LocalDiskConnector.js.map +0 -1
  305. package/skills/manage-files/scripts/copy.sh +0 -7
  306. package/skills/manage-files/scripts/create.sh +0 -7
  307. package/skills/manage-files/scripts/mkdir.sh +0 -7
  308. package/skills/manage-files/scripts/modify.sh +0 -7
  309. package/skills/manage-files/scripts/upload.sh +0 -16
  310. package/skills/manage-files/scripts/write.sh +0 -29
  311. package/skills/manage-sources/scripts/reembed.sh +0 -23
@@ -29,7 +29,7 @@ import {
29
29
  Trash2,
30
30
  type LucideIcon,
31
31
  } from 'lucide-react';
32
- import { meetsRequires } from '@neuralis/package-system/access';
32
+ import { isOwnerOrAdminEffective, meetsRequires } from '@neuralis/package-system/access';
33
33
  import { SkillLauncher, createPackageApi } from '@neuralis/package-system/client';
34
34
  import { cn } from '../lib/cn';
35
35
  import {
@@ -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 { fieldControl, rootFieldMeta, selectOptionsFrom, type OptionsRoute, type SelectOption } 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
 
@@ -487,10 +538,13 @@ export function SourcesPanel(props: Props) {
487
538
  const host = useFilesystemHost();
488
539
  const agentsState = host.useAgents();
489
540
  const userSlug = useMemo(() => deriveUserSlug(host.currentUser), [host.currentUser]);
490
- // R2b — owner/admin-effective (ordinal priority ≤ 2) gates the
491
- // `recognizesPackages` toggle in the UI. Server is the enforcement floor; this
492
- // only HIDES the control. Undefined priority (no signal) ⇒ hide (deny-by-default).
493
- const isOwnerEffective = (host.currentRole?.priority ?? 99) <= 2;
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
+ });
494
548
  // warded-wren — affordance mirror for the `sources.mount` gate that now covers
495
549
  // browse and the discoverable list. Same shape as `FileTree`'s: the ONE shared
496
550
  // kernel predicate, a copy at the boundary because the host keeps the grant
@@ -563,18 +617,18 @@ export function SourcesPanel(props: Props) {
563
617
  const healthOpen = useFilesystemStore((s) => s.vectorHealthOpen);
564
618
  const setVectorHealthOpen = useFilesystemStore((s) => s.setVectorHealthOpen);
565
619
  const [mountOpen, setMountOpen] = useState(false);
566
- const [browseOpen, setBrowseOpen] = useState(false);
567
- const [browseEntries, setBrowseEntries] = useState<Array<{ name: string; path: string }>>([]);
568
- const [browsePath, setBrowsePath] = useState('/');
569
- const [browseLoading, setBrowseLoading] = useState(false);
570
- const [browseError, setBrowseError] = useState<string | null>(null);
571
- const [dockerMounts, setDockerMounts] = useState<Array<{ name: string; path: string }>>([]);
572
- 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);
573
624
  const [kinds, setKinds] = useState<SourceKindRecord[]>([]);
574
- const [addKind, setAddKind] = useState<string>('local');
625
+ const [addKind, setAddKind] = useState<string>(HOST_KIND);
575
626
  const [kindsError, setKindsError] = useState<string | null>(null);
576
- // W3E — discoverable (well-known but unattached) source roots.
577
- 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));
578
632
 
579
633
  // Load registered source kinds once per mount — this is what powers the
580
634
  // schema-driven picker (replacing the old hardcoded KIND_META).
@@ -585,10 +639,10 @@ export function SourcesPanel(props: Props) {
585
639
  if (cancelled) return;
586
640
  setKinds(res.kinds ?? []);
587
641
  setKindsError(null);
588
- 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;
589
643
  if (preferred) {
590
644
  setAddKind(preferred.kind);
591
- const draft = buildAddDraft(preferred, sourceKeyIds);
645
+ const draft = buildAddDraft(preferred, sourceKeyIds, activeScope);
592
646
  autoKeyRef.current = draft.key;
593
647
  setAddDraft(draft);
594
648
  }
@@ -607,13 +661,22 @@ export function SourcesPanel(props: Props) {
607
661
  }, [kinds]);
608
662
  const selectedKind = kindByName.get(addKind) ?? null;
609
663
  const selectedKindSupportsSync = supportsSync(selectedKind);
610
- const rootField = rootFieldMeta(selectedKind);
664
+ const rootField = rootFieldMeta(selectedKind, addDraft.config, addDraft.transportPlatform);
611
665
  const selectedKindNeedsPath = rootField.needed;
612
- // The container-plane affordances: the `/mounts` volume picker, the
613
- // `neuralis:mount` callout, and Browse (which readdirs in the SERVER process,
614
- // i.e. the wrong machine for a host-plane root — that root lives on the paired
615
- // device chosen below, bounded by that device's own allowlist).
616
- 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
+ );
617
680
 
618
681
  /**
619
682
  * Open the Add-Source form prefilled from a discoverable suggestion.
@@ -696,33 +759,34 @@ export function SourcesPanel(props: Props) {
696
759
  /** Whether the key field still holds an auto-suggestion (safe to re-suggest). */
697
760
  const keyIsUntouched = (key: string) => key === '' || key === autoKeyRef.current;
698
761
 
699
- const load = useCallback(async () => {
762
+ const load = useCallback(async (fresh = true) => {
700
763
  if (!projectId) return; // Don't fetch without projectId — it would query the wrong directory
701
764
  setLoading(true);
702
765
  setError(null);
703
766
  try {
704
- // W3E — load source configs and discoverable suggestions in parallel.
705
- 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([
706
772
  listSourceConfigs({
707
773
  agentId,
708
774
  projectId,
709
775
  scope: activeScope,
710
776
  }, client),
711
- listDiscoverableSources({ agentId, projectId }, client)
712
- .catch(() => ({ suggestions: [] as DiscoverableSourceSuggestion[] })),
777
+ loadDiscoverable({ agentId, projectId }, { fresh }),
713
778
  ]);
714
779
  setConfigs(next);
715
780
  setDrafts(Object.fromEntries(next.map((c) => [c.source, buildDraft(c)])));
716
781
  setExpanded((prev) => Object.fromEntries(next.map((c) => [c.source, prev[c.source] ?? false])));
717
- setDiscoverable(discoverableRes.suggestions ?? []);
718
782
  } catch (err) {
719
783
  setError(err instanceof Error ? err.message : 'Failed to load sources');
720
784
  } finally {
721
785
  setLoading(false);
722
786
  }
723
- }, [activeScope, agentId, projectId]);
787
+ }, [activeScope, agentId, projectId, loadDiscoverable]);
724
788
 
725
- useEffect(() => { if (open) void load(); }, [open, load]);
789
+ useEffect(() => { if (open) void load(false); }, [open, load]);
726
790
 
727
791
  // On panel open, do a COUNT-FREE refresh: the connector-first list merge still
728
792
  // populates `sourceSummaries` (`s.sources`) with source discovery (observed-only
@@ -792,21 +856,6 @@ export function SourcesPanel(props: Props) {
792
856
  return () => clearTimeout(t);
793
857
  }, [successMsg]);
794
858
 
795
- // Docker mode: load available volume mounts when +Source form opens
796
- useEffect(() => {
797
- if (!showAddForm || !isDocker || !canMount) return;
798
- setDockerMountsLoading(true);
799
- browseDirectories({ path: '/mounts', agentId, ...(projectId ? { projectId } : {}) }, client)
800
- .then((result) => {
801
- // Filter out volumes already configured as sources
802
- const configuredSources = new Set(configs.map((c) => c.source));
803
- const unmounted = result.entries.filter((e) => !configuredSources.has(e.name));
804
- setDockerMounts(unmounted);
805
- })
806
- .catch(() => setDockerMounts([]))
807
- .finally(() => setDockerMountsLoading(false));
808
- }, [showAddForm, isDocker, canMount, agentId, projectId, configs]);
809
-
810
859
  const merged = useMemo(() => {
811
860
  const seen = new Set<string>();
812
861
  const rows: Array<SourceConfigRecord & {
@@ -835,6 +884,8 @@ export function SourcesPanel(props: Props) {
835
884
  // the row icon + name when the connector can't reach its backing store.
836
885
  live: s?.live,
837
886
  live_reason: s?.live_reason,
887
+ live_not_ready: s?.live_not_ready,
888
+ watch: s?.watch,
838
889
  kind_icon: s?.kind_icon,
839
890
  };
840
891
  });
@@ -842,7 +893,9 @@ export function SourcesPanel(props: Props) {
842
893
  if (activeScope === 'project') {
843
894
  for (const summary of sourceSummaries) {
844
895
  const source = sourceFromUri(summary.uri_root);
845
- 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;
846
899
  const meta = kindByName.get(summary.source_kind) ?? null;
847
900
  const counts = countsBySource[source] ?? summary;
848
901
  rows.push({
@@ -897,13 +950,13 @@ export function SourcesPanel(props: Props) {
897
950
  // "no change" server-side and would preserve the old config. Send an
898
951
  // explicit clear flag when the source HAD sync and the draft turned it off.
899
952
  const clearSync = !draft.sync && !!config.sync;
900
- // R2b — `recognizesPackages` is owner/admin-effective + local-only on the
901
- // server. `buildDraft` always carries it, so STRIP it for a non-owner (or
902
- // 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
903
956
  // unrelated description/sync edit. Owner-effective callers send the real value.
904
- const canEditRecognizes = isOwnerEffective && config.connection.kind === 'local';
905
- const { recognizesPackages: _rp, ...draftRest } = draft;
906
- 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;
907
960
  const updated = await updateSourceConfig({
908
961
  agentId,
909
962
  ...(projectId ? { projectId } : {}),
@@ -1004,17 +1057,18 @@ export function SourcesPanel(props: Props) {
1004
1057
  // the same time. Keep every hook above the guard.
1005
1058
  if (!open) return null;
1006
1059
 
1007
- const handleSync = async (source: string) => {
1060
+ const handleSync = async (source: string, confirmEmpty = false) => {
1008
1061
  setSyncingSource(source);
1009
1062
  setError(null);
1010
1063
  try {
1011
- await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source }, client);
1064
+ await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source, ...(confirmEmpty ? { confirmEmpty } : {}) }, client);
1012
1065
  await pollSyncJob(source);
1013
1066
  await load();
1014
1067
  await refresh({ agentId, projectId, withCounts: true });
1015
1068
  clearCachedCounts(source);
1016
1069
  } catch (err) {
1017
- 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');
1018
1072
  } finally {
1019
1073
  setSyncingSource(null);
1020
1074
  }
@@ -1149,25 +1203,7 @@ export function SourcesPanel(props: Props) {
1149
1203
  }
1150
1204
  };
1151
1205
 
1152
- const openBrowser = async (path: string) => {
1153
- setBrowseOpen(true);
1154
- setBrowseLoading(true);
1155
- setBrowseError(null);
1156
- try {
1157
- const result = await browseDirectories({ path, agentId, ...(projectId ? { projectId } : {}) }, client);
1158
- setBrowseEntries(result.entries);
1159
- setBrowsePath(path);
1160
- } catch {
1161
- // One stable sentence, matching the route: the server never returns the
1162
- // path or the errno, so there is nothing here worth surfacing verbatim.
1163
- setBrowseError('That path is not available.');
1164
- setBrowseEntries([]);
1165
- } finally {
1166
- setBrowseLoading(false);
1167
- }
1168
- };
1169
-
1170
- const selectBrowsePath = (path: string) => {
1206
+ const selectPickedFolder = (path: string) => {
1171
1207
  setAddDraft((d) => {
1172
1208
  // Re-suggest the key from the picked folder's name — but only while the
1173
1209
  // key is still an auto-suggestion. A manually-typed key is preserved.
@@ -1176,18 +1212,88 @@ export function SourcesPanel(props: Props) {
1176
1212
  autoKeyRef.current = key;
1177
1213
  return { ...d, path, key };
1178
1214
  });
1179
- setBrowseOpen(false);
1215
+ setPickerOpen(false);
1180
1216
  };
1181
1217
 
1182
- const handleAddSource = async () => {
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
+ );
1270
+ };
1271
+
1272
+ const handleAddSource = async (attachAnyway = false) => {
1183
1273
  if (!addDraft.key || (selectedKindNeedsPath && !addDraft.path)) return;
1184
1274
  setAddCreating(true);
1185
1275
  setError(null);
1186
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".
1187
1292
  const config = {
1188
- ...addDraft.config,
1293
+ ...Object.fromEntries(Object.entries(addDraft.config).filter(([, value]) => value !== '')),
1189
1294
  ...(selectedKindNeedsPath ? { root: addDraft.path } : {}),
1190
1295
  };
1296
+ const packageFolders = parsePackageFolders(addDraft.packageFolders);
1191
1297
  await createSource({
1192
1298
  agentId,
1193
1299
  ...(projectId ? { projectId } : {}),
@@ -1210,15 +1316,16 @@ export function SourcesPanel(props: Props) {
1210
1316
  ...(linesToList(addDraft.include).length > 0 ? { include: linesToList(addDraft.include) } : {}),
1211
1317
  },
1212
1318
  } : {}),
1213
- // R2b — only send when an owner/admin-effective caller opted a local
1214
- // source in. The server re-gates (owner/admin + local-only).
1215
- ...(isOwnerEffective && addKind === 'local' && addDraft.recognizesPackages
1216
- ? { 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 }
1217
1323
  : {}),
1218
1324
  }, client);
1219
1325
  setShowAddForm(false);
1326
+ setPickerOpen(false);
1220
1327
  {
1221
- const reset = buildAddDraft(selectedKind, sourceKeyIds);
1328
+ const reset = buildAddDraft(selectedKind, sourceKeyIds, activeScope);
1222
1329
  autoKeyRef.current = reset.key;
1223
1330
  setAddDraft(reset);
1224
1331
  }
@@ -1251,7 +1358,16 @@ export function SourcesPanel(props: Props) {
1251
1358
  <div className="inline-flex items-center gap-0.5">
1252
1359
  <button
1253
1360
  type="button"
1254
- 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
+ }}
1255
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"
1256
1372
  title="Add source"
1257
1373
  >
@@ -1287,7 +1403,7 @@ export function SourcesPanel(props: Props) {
1287
1403
 
1288
1404
  {/* Description */}
1289
1405
  <div className="mt-2 text-[10px] text-white/35 italic">
1290
- 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.
1291
1407
  </div>
1292
1408
 
1293
1409
  <div className="mt-2 flex items-center gap-1 rounded-md bg-white/[0.025] p-1">
@@ -1316,13 +1432,14 @@ export function SourcesPanel(props: Props) {
1316
1432
  <div className="mt-2 rounded-md bg-red-500/8 px-3 py-2 text-[10px] text-red-200/80">{error}</div>
1317
1433
  )}
1318
1434
 
1319
- {/* Source wizard (inline) */}
1435
+ {/* Source wizard (inline) — one page, in the order a person decides:
1436
+ kind → where → folder → who/what → sync → advanced. */}
1320
1437
  {showAddForm && (
1321
1438
  <div className="mt-2 rounded-lg bg-white/[0.03] border border-white/[0.08] p-3 space-y-2">
1322
1439
  <div className="text-[11px] font-semibold text-white/80">Add Source</div>
1323
1440
  {kindsError && (
1324
1441
  <div className="text-[9px] text-rose-300/80 bg-rose-500/10 rounded px-2 py-1">
1325
- Could not load source kinds: {kindsError}. Falling back to "local".
1442
+ Could not load source kinds: {kindsError}. Falling back to the host folder kind.
1326
1443
  </div>
1327
1444
  )}
1328
1445
  {kinds.length > 1 && (
@@ -1334,7 +1451,8 @@ export function SourcesPanel(props: Props) {
1334
1451
  const nextKind = e.target.value;
1335
1452
  const nextMeta = kindByName.get(nextKind) ?? null;
1336
1453
  setAddKind(nextKind);
1337
- const draft = buildAddDraft(nextMeta, sourceKeyIds);
1454
+ setPickerOpen(false);
1455
+ const draft = buildAddDraft(nextMeta, sourceKeyIds, activeScope);
1338
1456
  autoKeyRef.current = draft.key;
1339
1457
  setAddDraft(draft);
1340
1458
  }}
@@ -1347,10 +1465,64 @@ export function SourcesPanel(props: Props) {
1347
1465
  ))}
1348
1466
  </select>
1349
1467
  <div className="text-[9px] text-white/30 italic">
1350
- {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
+ )}
1351
1507
  </div>
1352
1508
  </div>
1353
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)}
1354
1526
  {selectedKind?.scopeSelectable && (
1355
1527
  <div className="space-y-1">
1356
1528
  <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Scope</div>
@@ -1369,215 +1541,27 @@ export function SourcesPanel(props: Props) {
1369
1541
  }}
1370
1542
  className={selectCls}
1371
1543
  >
1372
- {scopeOptions(selectedKind).map((scope) => (
1544
+ {scopeOptions(selectedKind, addDraft.config).map((scope) => (
1373
1545
  <option key={scope} value={scope}>{scope}</option>
1374
1546
  ))}
1375
1547
  </select>
1376
1548
  </div>
1377
1549
  )}
1378
- {showContainerPathChrome && isDocker ? (
1379
- <>
1380
- <div className="text-[10px] text-amber-300/70 bg-amber-500/8 rounded px-2 py-1.5">
1381
- Docker mode: host folders still need the CLI mount script:{' '}
1382
- <code className="text-amber-200/90 font-mono">neuralis mount add</code>{' '}
1383
- on the host, then restart the container. Package sources like Machine do not require a host path here.
1384
- </div>
1385
- {/* Unmounted volume mounts */}
1386
- {dockerMountsLoading ? (
1387
- <div className="flex items-center gap-1.5 py-1">
1388
- <Loader2 className="h-3 w-3 animate-spin text-white/30" />
1389
- <span className="text-[9px] text-white/35">Scanning volume mounts...</span>
1390
- </div>
1391
- ) : dockerMounts.length > 0 ? (
1392
- <div className="space-y-1">
1393
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">
1394
- Available volume mounts (not yet configured)
1395
- </div>
1396
- {dockerMounts.map((mount) => (
1397
- <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">
1398
- <Folder className="h-3 w-3 text-cyan-400/70 shrink-0" />
1399
- <span className="text-[10px] text-white/75 font-medium flex-1 truncate">{mount.name}</span>
1400
- <span className="text-[9px] text-white/30 font-mono truncate max-w-[120px]">{mount.path}</span>
1401
- <button
1402
- type="button"
1403
- onClick={() => {
1404
- setAddDraft((d) => ({ ...d, key: mount.name, path: mount.path }));
1405
- }}
1406
- 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"
1407
- >
1408
- + Add
1409
- </button>
1410
- </div>
1411
- ))}
1412
- </div>
1413
- ) : (
1414
- <div className="text-[9px] text-white/30 italic">
1415
- All volume mounts are configured as sources.
1416
- </div>
1417
- )}
1418
- </>
1419
- ) : (
1420
- <div className="text-[10px] text-white/35 italic">
1421
- {!selectedKindNeedsPath
1422
- ? 'This connector does not need a local path. Choose the scope and URI scheme; the package owns the runtime binding.'
1423
- : rootField.container
1424
- ? 'Mount any folder from your system. The path must be accessible to the server process.'
1425
- : 'A folder on the device you choose below \u2014 your own paired machine. It needs no volume mount here; that device\u2019s own allowlist (neuralis device grant) still limits what is reachable.'}
1426
- </div>
1427
- )}
1428
- <div className="space-y-2">
1429
- <div className={cn('grid gap-2', selectedKindNeedsPath ? 'grid-cols-2' : 'grid-cols-1')}>
1430
- <div className="space-y-1">
1431
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">URI scheme</div>
1432
- <input
1433
- type="text"
1434
- value={addDraft.key}
1435
- onChange={(e) => setAddDraft((d) => ({ ...d, key: e.target.value }))}
1436
- placeholder={computeSuggestedKey(addDraft.scope, addDraft.path)}
1437
- className={inputCls}
1438
- />
1439
- <div className="text-[8px] text-white/25 font-mono">{addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</div>
1440
- </div>
1441
- {selectedKindNeedsPath && (
1442
- <div className="space-y-1">
1443
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{rootField.label}</div>
1444
- <div className="flex gap-1">
1445
- <input
1446
- type="text"
1447
- value={addDraft.path}
1448
- onChange={(e) => setAddDraft((d) => ({ ...d, path: e.target.value }))}
1449
- placeholder={rootField.placeholder}
1450
- className={cn(inputCls, 'flex-1')}
1451
- />
1452
- {/* Browse: container-plane roots only (the route readdirs in
1453
- the SERVER process), outside Docker, and only for a caller
1454
- who could actually attach what they find. */}
1455
- {rootField.container && !isDocker && canMount && (
1456
- <button
1457
- type="button"
1458
- onClick={() => void openBrowser(addDraft.path || '/')}
1459
- 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"
1460
- title="Browse directories"
1461
- >
1462
- <FolderOpen className="h-3.5 w-3.5" />
1463
- </button>
1464
- )}
1465
- </div>
1466
- </div>
1467
- )}
1468
- </div>
1469
- {Object.entries(schemaObject(selectedKind).properties)
1470
- .filter(([key]) => key !== 'root')
1471
- .map(([key, prop]) => {
1472
- const field = fieldControl(prop, selectedKind?.packageId ?? '');
1473
- const setValue = (value: string) => setAddDraft((d) => ({
1474
- ...d,
1475
- config: { ...d.config, [key]: value },
1476
- }));
1477
- return (
1478
- <div key={key} className="space-y-1">
1479
- <div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">
1480
- {(prop as any)?.['x-neuralis']?.ui?.label ?? key}
1481
- </div>
1482
- {field.control === 'select' ? (
1483
- <OptionsRouteSelect
1484
- route={field.options}
1485
- revision={deviceRevision}
1486
- projectId={projectId}
1487
- agentId={agentId}
1488
- value={String(addDraft.config[key] ?? '')}
1489
- onChange={setValue}
1490
- />
1491
- ) : (
1492
- <input
1493
- type={field.numeric ? 'number' : 'text'}
1494
- value={String(addDraft.config[key] ?? '')}
1495
- onChange={(e) => setValue(e.target.value)}
1496
- placeholder={(prop as any)?.['x-neuralis']?.ui?.placeholder ?? (prop as any)?.description ?? key}
1497
- className={inputCls}
1498
- />
1499
- )}
1500
- {field.control === 'text' && field.refused ? (
1501
- <div className="text-[9px] text-amber-300/80">{field.refused}</div>
1502
- ) : null}
1503
- </div>
1504
- );
1505
- })}
1506
- {/* Directory browser (non-Docker only) */}
1507
- {showContainerPathChrome && !isDocker && browseOpen && (
1508
- <div className="rounded-md bg-white/[0.03] border border-white/[0.1] overflow-hidden">
1509
- <div className="flex items-center gap-1.5 px-2 py-1.5 bg-white/[0.02] border-b border-white/[0.06]">
1510
- <FolderOpen className="h-3 w-3 text-cyan-400 shrink-0" />
1511
- <span className="text-[9px] font-mono text-white/60 truncate flex-1">{browsePath}</span>
1512
- <button type="button" onClick={() => setBrowseOpen(false)}
1513
- className="text-[9px] text-white/40 hover:text-white/70 px-1">Close</button>
1514
- </div>
1515
- {browseError && (
1516
- <div className="px-2 py-1 text-[9px] text-red-300/80">{browseError}</div>
1517
- )}
1518
- {browseLoading ? (
1519
- <div className="flex items-center justify-center py-3">
1520
- <Loader2 className="h-3 w-3 animate-spin text-white/30" />
1521
- </div>
1522
- ) : (
1523
- <div className="max-h-[180px] overflow-y-auto">
1524
- {browsePath !== '/' && (
1525
- <button
1526
- type="button"
1527
- onClick={() => {
1528
- const parent = browsePath.replace(/[/\\][^/\\]+$/, '') || '/';
1529
- void openBrowser(parent);
1530
- }}
1531
- className="w-full flex items-center gap-1.5 px-2 py-1 text-left hover:bg-white/[0.04] transition"
1532
- >
1533
- <ChevronUp className="h-3 w-3 text-white/30" />
1534
- <span className="text-[10px] text-white/50">..</span>
1535
- </button>
1536
- )}
1537
- {browseEntries.length === 0 && !browseLoading && (
1538
- <div className="px-2 py-2 text-[9px] text-white/30 italic">No subdirectories</div>
1539
- )}
1540
- {browseEntries.map((entry) => (
1541
- <div key={entry.path} className="flex items-center gap-0.5 hover:bg-white/[0.04] transition">
1542
- <button
1543
- type="button"
1544
- onClick={() => void openBrowser(entry.path)}
1545
- className="flex items-center gap-1.5 px-2 py-1 flex-1 text-left min-w-0"
1546
- >
1547
- <Folder className="h-3 w-3 text-cyan-400/70 shrink-0" />
1548
- <span className="text-[10px] text-white/75 truncate">{entry.name}</span>
1549
- </button>
1550
- <button
1551
- type="button"
1552
- onClick={() => selectBrowsePath(entry.path)}
1553
- className="px-2 py-1 text-[8px] font-medium text-cyan-300/70 hover:text-cyan-200 shrink-0"
1554
- >
1555
- Select
1556
- </button>
1557
- </div>
1558
- ))}
1559
- <button
1560
- type="button"
1561
- onClick={() => selectBrowsePath(browsePath)}
1562
- 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"
1563
- >
1564
- <CheckCircle2 className="h-3 w-3 text-cyan-400 shrink-0" />
1565
- <span className="text-[10px] font-medium text-cyan-300/80">Select this folder</span>
1566
- <span className="text-[9px] text-white/30 font-mono truncate ml-auto">{browsePath}</span>
1567
- </button>
1568
- </div>
1569
- )}
1570
- </div>
1571
- )}
1572
- </div>
1573
1550
  <div className="flex items-center gap-2">
1574
1551
  <span className="text-[9px] font-semibold uppercase tracking-wider text-white/35 w-16 shrink-0">Access</span>
1575
1552
  {([
1576
1553
  { key: 'read' as const, label: 'Read', icon: Eye },
1577
1554
  { key: 'write' as const, label: 'Write', icon: Pencil },
1578
1555
  { key: 'exec' as const, label: 'Exec', icon: Terminal },
1579
- ]).map(({ key, label, icon: PIcon }) => (
1580
- <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(
1581
1565
  'inline-flex items-center gap-1 rounded px-2 py-1 text-[9px] cursor-pointer transition',
1582
1566
  addDraft[key] ? 'bg-white/[0.06] text-white/85' : 'bg-white/[0.02] text-white/40',
1583
1567
  )}>
@@ -1588,20 +1572,25 @@ export function SourcesPanel(props: Props) {
1588
1572
  </label>
1589
1573
  ))}
1590
1574
  </div>
1591
- <div className="space-y-1">
1592
- <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>
1593
- {/* The connector's own text as the PLACEHOLDER, not as a value:
1594
- leaving it blank resolves through the same chain at read time,
1595
- so the agent still gets the package's wording — and it keeps
1596
- tracking the package instead of freezing a copy here. */}
1597
- <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} />
1598
- </div>
1599
1575
  {selectedKindSupportsSync && (
1600
1576
  <div className="flex items-center gap-2">
1601
1577
  <label className="inline-flex items-center gap-1.5 text-[10px] text-white/80 cursor-pointer">
1602
1578
  <input type="checkbox" checked={addDraft.syncEnabled} onChange={(e) => setAddDraft((d) => ({ ...d, syncEnabled: e.target.checked }))} className="accent-emerald-400 h-3 w-3" />
1603
1579
  Enable sync
1604
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
+ )}
1605
1594
  </div>
1606
1595
  )}
1607
1596
  {selectedKindSupportsSync && addDraft.syncEnabled && (
@@ -1617,22 +1606,48 @@ export function SourcesPanel(props: Props) {
1617
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>
1618
1607
  </div>
1619
1608
  )}
1620
- {addKind === 'local' && isOwnerEffective && (
1621
- <div className="space-y-1">
1622
- <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>
1623
1616
  <input
1624
- type="checkbox"
1625
- checked={addDraft.recognizesPackages}
1626
- onChange={(e) => setAddDraft((d) => ({ ...d, recognizesPackages: e.target.checked }))}
1627
- 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}
1628
1622
  />
1629
- <span>
1630
- Recognize packages
1631
- <span className="block text-[9px] text-white/35 normal-case font-normal">
1632
- 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.
1633
- </span>
1634
- </span>
1635
- </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>
1636
1651
  </div>
1637
1652
  )}
1638
1653
  <div className="flex items-center gap-1.5 pt-1">
@@ -1657,7 +1672,7 @@ export function SourcesPanel(props: Props) {
1657
1672
  {grouped.map(([origin, rows]) => (
1658
1673
  <div key={origin}>
1659
1674
  {/* Origin group header — small, indented a touch LEFT of its rows,
1660
- replaces the per-row "first-party" badge. */}
1675
+ replaces the per-row "native" badge. */}
1661
1676
  <div className="-ml-0.5 mb-0.5 flex items-center gap-1.5 px-1">
1662
1677
  <Package className="h-3 w-3 shrink-0 text-white/30" />
1663
1678
  <span className="truncate font-mono text-[10px] font-medium text-white/40">{origin}</span>
@@ -1689,13 +1704,14 @@ export function SourcesPanel(props: Props) {
1689
1704
  const dirty = editable && isDirty(config, draft);
1690
1705
  const tp = parseTrigger(syncDraft.trigger);
1691
1706
  const sections = subSections[config.source] ?? {};
1707
+ const watchBadge = sourceWatchBadge(config.watch, config.live_reason);
1692
1708
 
1693
1709
  return (
1694
1710
  <div key={fileKey(sourceRootUri(config.source))} className="rounded-sm">
1695
1711
  {/* Accordion header: selection and enabled actions are siblings. */}
1696
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">
1697
1713
  <button type="button" onClick={() => toggleSource(config.source)} aria-expanded={isOpen}
1698
- title={disconnected ? sourceLiveReasonLabel(config.live_reason) : undefined}
1714
+ title={disconnected ? sourceLiveReasonLabel(config.live_reason, { kind: config.connection.kind, notReady: config.live_not_ready }) : undefined}
1699
1715
  className="min-w-0 flex-1 flex items-center gap-2 text-left">
1700
1716
  {/* Git indicator (G2.1) — a GitBranch overlay on a git-repo
1701
1717
  source's icon, ORTHOGONAL to the sync tone (visualTone still
@@ -1709,6 +1725,17 @@ export function SourcesPanel(props: Props) {
1709
1725
  <SourceIcon className={cn('h-4 w-4 shrink-0', visualTone.iconClassName)} />
1710
1726
  )}
1711
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}
1712
1739
  {config.git?.isRepo && config.git.branch ? (
1713
1740
  <span
1714
1741
  className="inline-flex max-w-[96px] shrink-0 items-center gap-0.5 text-[10px] text-violet-300/70"
@@ -1812,10 +1839,16 @@ export function SourcesPanel(props: Props) {
1812
1839
  </div>
1813
1840
 
1814
1841
  {configSupportsSync && !config.sync?.lastSync ? (
1815
- <div className="rounded-md border border-sky-300/15 bg-sky-500/5 px-2 py-1.5 text-[10px] text-sky-100/70">
1816
- Never synced — run a sync to index this source and load its packages
1817
- (skills, rules, instructions) for agents.
1818
- </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
+ )
1819
1852
  ) : null}
1820
1853
 
1821
1854
  {config.observed ? (
@@ -1878,27 +1911,15 @@ export function SourcesPanel(props: Props) {
1878
1911
  )}
1879
1912
  </div>
1880
1913
 
1881
- {/* R2b — Recognize packages toggle. Owner/admin-effective +
1882
- local-kind only (server re-gates). Makes this source a package
1883
- source too; advertised only within its own scope. */}
1884
- {editable && isOwnerEffective && (meta?.kind ?? config.connection.kind) === 'local' && (
1885
- <div className="pl-0">
1886
- <label className="flex items-start gap-1.5 py-1 cursor-pointer">
1887
- <Package className="h-3 w-3 text-cyan-400 shrink-0 mt-0.5" />
1888
- <input
1889
- type="checkbox"
1890
- checked={draft.recognizesPackages ?? false}
1891
- onChange={(e) => updateDraft(config.source, (d) => ({ ...d, recognizesPackages: e.target.checked }))}
1892
- className="accent-cyan-400 h-3 w-3 mt-0.5"
1893
- />
1894
- <span className="text-[10px] text-white/70 leading-tight">
1895
- Recognize packages
1896
- <span className="block text-[9px] text-white/35 normal-case font-normal">
1897
- Load packages found in this folder (in addition to <code>_packages/</code>) — applied on save; advertised only within this source&apos;s scope.
1898
- </span>
1899
- </span>
1900
- </label>
1901
- </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
+ />
1902
1923
  )}
1903
1924
 
1904
1925
  {/* Permissions section — admin-style compact editor.
@@ -2069,7 +2090,10 @@ export function SourcesPanel(props: Props) {
2069
2090
  <span>Last sync: +{config.sync.lastSyncResult.filesAdded} -{config.sync.lastSyncResult.filesRemoved} ~{config.sync.lastSyncResult.filesUpdated}</span>
2070
2091
  <span>({(config.sync.lastSyncResult.durationMs / 1000).toFixed(1)}s)</span>
2071
2092
  {config.sync.lastSyncResult.error && (
2072
- <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>
2073
2097
  )}
2074
2098
  </div>
2075
2099
  )}
@@ -2213,9 +2237,7 @@ export function SourcesPanel(props: Props) {
2213
2237
  <SourceDeleteConfirm
2214
2238
  source={config.source}
2215
2239
  vectorCount={typeof config.indexed_count === 'number' ? config.indexed_count : null}
2216
- isDockerMount={Boolean(
2217
- (config.connection?.config as { hostRoot?: unknown } | undefined)?.hostRoot,
2218
- )}
2240
+ isDockerMount={isDockerBind(config)}
2219
2241
  busy={deleting}
2220
2242
  onCancel={() => setDeleteConfirmFor(null)}
2221
2243
  onConfirm={(opts) => void handleDelete(config.source, opts)}
@@ -2401,6 +2423,7 @@ const DIMMED_OPTION_STYLE = {
2401
2423
  */
2402
2424
  function OptionsRouteSelect({
2403
2425
  route,
2426
+ emptyLabel,
2404
2427
  revision,
2405
2428
  projectId,
2406
2429
  agentId,
@@ -2408,11 +2431,14 @@ function OptionsRouteSelect({
2408
2431
  onChange,
2409
2432
  }: {
2410
2433
  route: OptionsRoute;
2434
+ /** An optional field's empty choice (`fieldControl`), selectable — absent ⇒ a disabled "Choose…". */
2435
+ emptyLabel?: string;
2411
2436
  revision: number;
2412
2437
  projectId?: string | null;
2413
2438
  agentId: string;
2414
2439
  value: string;
2415
- onChange: (value: string) => void;
2440
+ /** The picked value and its row (`undefined` for the empty choice). */
2441
+ onChange: (value: string, option?: SelectOption) => void;
2416
2442
  }) {
2417
2443
  const [options, setOptions] = useState<SelectOption[] | null>(null);
2418
2444
  const [loadError, setLoadError] = useState<string | null>(null);
@@ -2443,10 +2469,10 @@ function OptionsRouteSelect({
2443
2469
 
2444
2470
  if (loadError) return <div className="text-[9px] text-red-300/80">{loadError}</div>;
2445
2471
  if (options === null) return <div className="text-[9px] text-white/30 animate-pulse">Loading…</div>;
2446
- if (options.length === 0) return <div className="text-[9px] text-white/40 italic">Nothing to choose from.</div>;
2472
+ if (options.length === 0 && !emptyLabel) return <div className="text-[9px] text-white/40 italic">Nothing to choose from.</div>;
2447
2473
  return (
2448
- <select value={value} onChange={(e) => onChange(e.target.value)} className={selectCls}>
2449
- <option value="" disabled>Choose…</option>
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>}
2450
2476
  {options.map((o) => (
2451
2477
  <option key={o.value} value={o.value} style={o.dimmed ? DIMMED_OPTION_STYLE : undefined}>
2452
2478
  {o.label}
@@ -2455,3 +2481,37 @@ function OptionsRouteSelect({
2455
2481
  </select>
2456
2482
  );
2457
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
+ }