@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
@@ -1,31 +1,24 @@
1
1
  /**
2
2
  * How a connector kind's config fields render in the Add-Source form — derived
3
3
  * from the DECLARATION, never from the kind name and never from `isDocker`.
4
- * The ROOT field first; the other fields' closed control set is `fieldControl`
5
- * below.
6
4
  *
7
- * Every other field in that form renders from its declared
8
- * `x-neuralis.ui`; `root` was the one hand-written, kind-BLIND exception, which
9
- * is why the Host Machine kind used to show the `local` connector's Docker
10
- * volume picker, its `pnpm neuralis:mount add` callout and a `/mounts/...`
11
- * placeholder — while its own declaration already said `Host root path` /
12
- * `/home/you/projects` (warded-wren, closing the UI half of F-B5).
5
+ * The form is one page, in the order a person decides: the kind, then WHERE
6
+ * (a field the declaration marks `x-neuralis.ui.transport` — the `host` kind's
7
+ * `device`), then the ROOT folder (with Browse when the root declares
8
+ * `ui.browse`), then the plain fields, and the fields marked `ui.advanced`
9
+ * folded under "Advanced". `configFields` returns that order.
13
10
  *
14
- * `control` carries the distinction that matters:
15
- * - `container-path` — the root lives in the CONTAINER filesystem, so the
16
- * `/mounts` volume picker, the mount-CLI callout and Browse all apply.
17
- * - `host-path` — the root lives on a paired device, reached through that
18
- * device's broker. None of that chrome applies, and Browse least of all:
19
- * `GET /sources/browse` readdirs in the SERVER process, i.e. the wrong
20
- * machine. A host root is bounded by that device's own allowlist.
21
- * - `path` (or absent) — the pre-existing form. Treated as container-plane,
22
- * so an externally authored connector keeps working unchanged.
23
- *
24
- * Deliberately NOT keyed on `isHostPlaneKind`: that predicate is a NAME test
25
- * (`kind === 'host' || kind.startsWith('host-')`) whose own docstring says a
26
- * future `ssh`/`smb` connector joins the plane by being added to its body. A
27
- * negative test on that axis would hand the container chrome to every future
28
- * non-`local` kind — re-creating, in the UI, the kind-blindness this replaces.
11
+ * The root's meaning follows the transport, not a control name: until the
12
+ * transport field holds a value the root is a path in THIS server's filesystem
13
+ * (`container` — the `/mounts` suggestions and the mount-CLI callout apply in
14
+ * Docker); with a device chosen it is a path ON THAT DEVICE
15
+ * (`deviceLabel`, and `devicePlaceholders[<the device's platform>]` else
16
+ * `devicePlaceholder`), browsed through that device's broker and bounded by
17
+ * the folders its owner allowed. A root control is `container-path`
18
+ * or the pre-existing bare `path`, so an externally authored connector keeps
19
+ * working unchanged. Keyed on the DECLARATION and the form's own values, never
20
+ * on a kind name — a name test would hand one kind's chrome to every future
21
+ * kind it does not list.
29
22
  */
30
23
  export type SourceKindFormMeta = {
31
24
  kind: string;
@@ -39,6 +32,8 @@ export type RootFieldMeta = {
39
32
  container: boolean;
40
33
  label: string;
41
34
  placeholder: string;
35
+ /** The root declares `ui.browse`: the form offers the folder picker (on this server, or on the chosen device). */
36
+ browse: boolean;
42
37
  };
43
38
 
44
39
  type SchemaShape = { required: string[]; properties: Record<string, any> };
@@ -52,21 +47,94 @@ export function schemaShape(meta: SourceKindFormMeta | null | undefined): Schema
52
47
  };
53
48
  }
54
49
 
55
- const PATH_CONTROLS = new Set(['path', 'container-path', 'host-path']);
50
+ const PATH_CONTROLS = new Set(['path', 'container-path']);
56
51
 
57
- export function rootFieldMeta(meta: SourceKindFormMeta | null | undefined): RootFieldMeta {
52
+ /**
53
+ * `platform` is the chosen device's claimed platform (its WHERE option's
54
+ * `platform`): the placeholder takes the declared `devicePlaceholders` entry
55
+ * for it, else the `devicePlaceholder` default — so a Windows device hints
56
+ * `C:\…`, never another machine's path style.
57
+ */
58
+ export function rootFieldMeta(
59
+ meta: SourceKindFormMeta | null | undefined,
60
+ values: Readonly<Record<string, unknown>> = {},
61
+ platform?: string,
62
+ ): RootFieldMeta {
58
63
  const schema = schemaShape(meta);
59
64
  const root = schema.properties.root;
60
65
  const ui = root?.['x-neuralis']?.ui;
61
66
  const control = typeof ui?.control === 'string' ? (ui.control as string) : undefined;
67
+ const onDevice = transportChosen(meta, values);
68
+ const pick = (deviceKey: string, key: string, fallback: string): string =>
69
+ onDevice && typeof ui?.[deviceKey] === 'string' ? ui[deviceKey] : typeof ui?.[key] === 'string' ? ui[key] : fallback;
70
+ const perPlatform = onDevice && platform ? ui?.devicePlaceholders?.[platform] : undefined;
62
71
  return {
63
72
  needed: Boolean(root && (schema.required.includes('root') || (control && PATH_CONTROLS.has(control)))),
64
- container: control !== 'host-path',
65
- label: typeof ui?.label === 'string' ? ui.label : 'Path',
66
- placeholder: typeof ui?.placeholder === 'string' ? ui.placeholder : '',
73
+ container: !onDevice,
74
+ label: pick('deviceLabel', 'label', 'Path'),
75
+ placeholder: typeof perPlatform === 'string' ? perPlatform : pick('devicePlaceholder', 'placeholder', ''),
76
+ browse: ui?.browse === true,
67
77
  };
68
78
  }
69
79
 
80
+ /** One non-root config field, with the hints the form reads off its `x-neuralis.ui`. */
81
+ export type ConfigField = { key: string; prop: unknown; label: string; help?: string; placeholder?: string };
82
+
83
+ /**
84
+ * The non-root config fields in the order the form renders them: `transport`
85
+ * (WHERE — before the root, because it decides what the root means), `fields`,
86
+ * and `advanced` (`ui.advanced`, folded). A field the schema's exclusion hides
87
+ * for the current values (`hiddenByExclusion`) is not offered at all.
88
+ */
89
+ export function configFields(
90
+ meta: SourceKindFormMeta | null | undefined,
91
+ values: Readonly<Record<string, unknown>>,
92
+ ): { transport: ConfigField[]; fields: ConfigField[]; advanced: ConfigField[] } {
93
+ const out = { transport: [] as ConfigField[], fields: [] as ConfigField[], advanced: [] as ConfigField[] };
94
+ for (const [key, prop] of Object.entries(schemaShape(meta).properties)) {
95
+ if (key === 'root' || hiddenByExclusion(meta, values, key)) continue;
96
+ const ui = prop?.['x-neuralis']?.ui ?? {};
97
+ const field: ConfigField = {
98
+ key,
99
+ prop,
100
+ label: typeof ui.label === 'string' ? ui.label : key,
101
+ ...(typeof ui.help === 'string' ? { help: ui.help } : {}),
102
+ ...(typeof ui.placeholder === 'string' ? { placeholder: ui.placeholder } : {}),
103
+ };
104
+ (ui.transport === true ? out.transport : ui.advanced === true ? out.advanced : out.fields).push(field);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** The kind's one-line card text (`configSchema["x-neuralis"].ui.summary`); the long `description` is the agent's. */
110
+ export function kindSummary(meta: SourceKindFormMeta | null | undefined): string | null {
111
+ const summary = (meta as any)?.configSchema?.['x-neuralis']?.ui?.summary;
112
+ return typeof summary === 'string' && summary ? summary : null;
113
+ }
114
+
115
+ /**
116
+ * Has the form chosen a TRANSPORT — a value in a field the declaration marks
117
+ * `x-neuralis.ui.transport` (the `host` kind's `device`)? Then the root is a
118
+ * path on that device, not in this server's filesystem.
119
+ */
120
+ export function transportChosen(meta: SourceKindFormMeta | null | undefined, values: Readonly<Record<string, unknown>>): boolean {
121
+ const { properties } = schemaShape(meta);
122
+ return Object.entries(properties).some(([key, prop]) =>
123
+ prop?.['x-neuralis']?.ui?.transport === true && typeof values[key] === 'string' && values[key] !== '');
124
+ }
125
+
126
+ /**
127
+ * Is this field hidden because a field it is mutually exclusive with (the
128
+ * schema's `not: { required: [...] }`) already holds a value? The kind's
129
+ * validator refuses the pair, so the form never offers it.
130
+ */
131
+ export function hiddenByExclusion(meta: SourceKindFormMeta | null | undefined, values: Readonly<Record<string, unknown>>, key: string): boolean {
132
+ const exclusive = (meta as any)?.configSchema?.not?.required;
133
+ if (!Array.isArray(exclusive) || !exclusive.includes(key)) return false;
134
+ return exclusive.some((other: unknown) => other !== key && typeof other === 'string'
135
+ && values[other] !== undefined && values[other] !== '');
136
+ }
137
+
70
138
  /**
71
139
  * How a NON-root config field renders. A CLOSED set: `text` (the default — a
72
140
  * plain input, `number` for a numeric type) and `select`, whose options a
@@ -81,7 +149,7 @@ export function rootFieldMeta(meta: SourceKindFormMeta | null | undefined): Root
81
149
  */
82
150
  export type FieldControl =
83
151
  | { control: 'text'; numeric: boolean; refused?: string }
84
- | { control: 'select'; numeric: boolean; options: OptionsRoute };
152
+ | { control: 'select'; numeric: boolean; options: OptionsRoute; emptyLabel?: string };
85
153
 
86
154
  /** A validated options route: one package-relative path + its query. */
87
155
  export type OptionsRoute = { slug: string; path: string; params: Record<string, string> };
@@ -90,7 +158,7 @@ const ROUTE_PATH = /^[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9._~-]+)*$/;
90
158
  const ROUTE_QUERY_PART = /^[A-Za-z0-9._~-]+$/;
91
159
 
92
160
  /**
93
- * The `/api/packages/<slug>` segment of a registry id: the first-party scope is
161
+ * The `/api/packages/<slug>` segment of a registry id: the native scope is
94
162
  * dropped (`@neuralis/agent-core` → `agent-core`, the same rule the route
95
163
  * gateway resolves), any other id travels as ONE encoded segment.
96
164
  */
@@ -128,24 +196,33 @@ export function parseOptionsRoute(raw: unknown, packageId: string): OptionsRoute
128
196
  return { slug: packageRouteSlug(packageId), path: path as string, params };
129
197
  }
130
198
 
199
+ /**
200
+ * An OPTIONAL select (`ui.optional`) offers its `emptyLabel` as a real choice —
201
+ * the empty value (for the `host` kind's `device`: "This server"); a required
202
+ * one only lists the route's rows.
203
+ */
131
204
  export function fieldControl(prop: unknown, packageId: string): FieldControl {
132
- const p = (prop ?? {}) as { type?: unknown; 'x-neuralis'?: { ui?: { control?: unknown; optionsRoute?: unknown } } };
205
+ const p = (prop ?? {}) as { type?: unknown; 'x-neuralis'?: { ui?: { control?: unknown; optionsRoute?: unknown; optional?: unknown; emptyLabel?: unknown } } };
133
206
  const numeric = p.type === 'number' || p.type === 'integer';
134
207
  const ui = p['x-neuralis']?.ui;
135
208
  if (ui?.control !== 'select') return { control: 'text', numeric };
136
209
  const route = parseOptionsRoute(ui.optionsRoute, packageId);
137
210
  if ('refused' in route) return { control: 'text', numeric, refused: route.refused };
138
- return { control: 'select', numeric, options: route };
211
+ const emptyLabel = ui.optional === true ? (typeof ui.emptyLabel === 'string' && ui.emptyLabel ? ui.emptyLabel : 'None') : undefined;
212
+ return { control: 'select', numeric, options: route, ...(emptyLabel ? { emptyLabel } : {}) };
139
213
  }
140
214
 
141
- /** One row of a `select`: the stored value, its label, and whether it reads dimmed. */
142
- export type SelectOption = { value: string; label: string; dimmed: boolean };
215
+ /** One row of a `select`: the stored value, its label, whether it reads dimmed, and a device row's claimed `platform`. */
216
+ export type SelectOption = { value: string; label: string; dimmed: boolean; platform?: string };
217
+
218
+ /** The OS word a device row's `platform` reads as; any other token prints as it came. */
219
+ const PLATFORM_WORDS: Readonly<Record<string, string>> = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' };
143
220
 
144
221
  /**
145
222
  * The options an `optionsRoute` answered. The answer is a JSON object whose
146
- * FIRST array-valued property lists the rows; a row is `{id, name?, status?,
147
- * confinement?}` (the shape of a device list). The label is
148
- * `name · status · confinement`; a `revoked` row is not offered at all; any
223
+ * FIRST array-valued property lists the rows; a row is `{id, name?, platform?,
224
+ * status?, confinement?}` (the shape of a device list). The label is
225
+ * `name · OS · status · confinement`; a `revoked` row is not offered at all; any
149
226
  * status other than `online` reads dimmed but stays selectable — attaching a
150
227
  * source to an offline device is allowed, it simply answers once the device is
151
228
  * back. Rows without a string `id` are dropped.
@@ -157,14 +234,16 @@ export function selectOptionsFrom(answer: unknown): SelectOption[] {
157
234
  const out: SelectOption[] = [];
158
235
  for (const row of rows) {
159
236
  if (!row || typeof row !== 'object') continue;
160
- const r = row as { id?: unknown; name?: unknown; status?: unknown; confinement?: unknown };
237
+ const r = row as { id?: unknown; name?: unknown; platform?: unknown; status?: unknown; confinement?: unknown };
161
238
  if (typeof r.id !== 'string' || r.id.length === 0) continue;
162
239
  const status = typeof r.status === 'string' ? r.status : undefined;
163
240
  if (status === 'revoked') continue;
241
+ const platform = typeof r.platform === 'string' && r.platform ? r.platform : undefined;
164
242
  const parts = [typeof r.name === 'string' && r.name ? r.name : r.id];
243
+ if (platform) parts.push(PLATFORM_WORDS[platform] ?? platform);
165
244
  if (status) parts.push(status.replace(/_/g, ' '));
166
245
  if (typeof r.confinement === 'string') parts.push(r.confinement);
167
- out.push({ value: r.id, label: parts.join(' · '), dimmed: status !== undefined && status !== 'online' });
246
+ out.push({ value: r.id, label: parts.join(' · '), dimmed: status !== undefined && status !== 'online', ...(platform ? { platform } : {}) });
168
247
  }
169
248
  return out;
170
249
  }
@@ -326,27 +326,121 @@ export function getSourceVisualTone(info: SourceInfo): {
326
326
  // gone. Returned as a plain string for the parent button's `title` tooltip.
327
327
  /** Display strings for the `SourceLiveReason` enum (the wire value is an enum to
328
328
  * keep it parseable / leak-free — the human text is mapped here, in the UI). */
329
+ /** How a device's owner updates the CLI that serves it: the desktop app carries it, an npm install updates by npm (`neuralis update` is the SERVER's verb, never a device's). */
330
+ const DEVICE_UPDATE_STEPS = 'update the Neuralis desktop app on that computer, or run npm i -g @neuralis/cli@latest there, then neuralis device install';
331
+
329
332
  const LIVE_REASON_LABEL: Record<string, string> = {
330
333
  runtime_offline: 'Machine offline — container not running',
331
334
  fs_unreachable: 'Filesystem unreachable — mount missing',
332
335
  fs_permission: 'Filesystem unreadable — permission denied',
333
336
  store_unreachable: 'Store offline — backing service unreachable',
337
+ broker_outdated: `Device needs an update — ${DEVICE_UPDATE_STEPS}`,
338
+ devices_disabled: 'Devices are turned off on this server — an admin turns them on in Admin → Config',
339
+ device_not_ready: 'Device not ready — neuralis device status on that computer says why',
340
+ };
341
+
342
+ /** A `host` folder is offline only through its device's link (an in-process folder answers `fs_*`). */
343
+ const DEVICE_OFFLINE_LABEL = 'Device offline — it is not connected to this server';
344
+
345
+ /** What a device's owner fixes there, per broker readiness code (`DeviceNotReadyCode`). */
346
+ const DEVICE_NOT_READY_LABEL: Record<string, string> = {
347
+ ceiling_missing_or_empty: 'no folder is allowed on it yet (neuralis device grant on that computer)',
348
+ sandbox_helper_missing: 'its sandbox helper is not usable (neuralis device install on that computer)',
349
+ socket_isolation_failed: 'its sandbox cannot keep commands away from the computer\'s own control sockets (neuralis device status on that computer says how to fix it)',
350
+ secret_file_missing_or_too_short: 'its pairing secret is missing (pair it again)',
351
+ scratch_root_missing: 'its scratch folder is missing (neuralis device install on that computer)',
334
352
  };
335
353
 
336
354
  /** Map a `SourceLiveReason` (or undefined) to its display string. Shared by the
337
- * file tree tooltip and the Sources panel so both render the same text. */
338
- export function sourceLiveReasonLabel(reason?: string | null): string {
355
+ * file tree tooltip, the Sources panel and the read/preview failures so all
356
+ * render the same text: `kind` words an offline `host` folder as its device,
357
+ * `notReady` names a not-ready device's own codes. */
358
+ export function sourceLiveReasonLabel(reason?: string | null, opts: { kind?: string | null; notReady?: readonly unknown[] | null } = {}): string {
359
+ if (reason === 'runtime_offline' && opts.kind === 'host') return DEVICE_OFFLINE_LABEL;
360
+ if (reason === 'device_not_ready') {
361
+ const known = (opts.notReady ?? []).flatMap((code) => (typeof code === 'string' && DEVICE_NOT_READY_LABEL[code] ? [DEVICE_NOT_READY_LABEL[code]] : []));
362
+ if (known.length > 0) return `Device not ready — ${known.join('; ')}`;
363
+ }
339
364
  return (reason ? LIVE_REASON_LABEL[reason] : undefined)
340
365
  ?? 'Disconnected — connector cannot reach its backing filesystem';
341
366
  }
342
367
 
368
+ /**
369
+ * The source row's live-watch badge: `null` while the watch covers the whole
370
+ * source (or none is meant for it), else the label and the tooltip that says
371
+ * what is and is not live right now.
372
+ */
373
+ export function sourceWatchBadge(
374
+ watch: string | null | undefined,
375
+ liveReason?: string | null,
376
+ ): { label: string; title: string; tone: 'pending' | 'warn' } | null {
377
+ if (watch === 'starting') {
378
+ return {
379
+ label: 'Live: starting',
380
+ title: 'Live changes are starting: the watcher is still walking this source\'s folders. Until it is ready, a change can arrive only with the next reconcile or Sync now.',
381
+ tone: 'pending',
382
+ };
383
+ }
384
+ if (watch === 'partial') {
385
+ return {
386
+ label: 'Live: partial',
387
+ title: 'Live changes cover part of this source: it has more folders than the watcher budget (brainWatchMaxFolders) or this computer\'s watch limit allows. Changes in the folders past it, new folders there included, are picked up by the next full reconcile (every 24 h by default) or by Sync now.',
388
+ tone: 'warn',
389
+ };
390
+ }
391
+ if (watch === 'off') {
392
+ return {
393
+ label: 'Live: off',
394
+ title: liveReason === 'broker_outdated'
395
+ ? `No live changes for this source: its device runs a CLI too old to watch — ${DEVICE_UPDATE_STEPS}. Changes arrive with the regular sync.`
396
+ : `No live changes for this source: its watch is not running (a device may be offline, or run a CLI too old to watch — ${DEVICE_UPDATE_STEPS}). Changes arrive with the regular sync.`,
397
+ tone: 'warn',
398
+ };
399
+ }
400
+ return null;
401
+ }
402
+
403
+ /** A device refusal code its owner can act on, in words — the file read, the preview and the source row say it alike. */
404
+ const DEVICE_REFUSAL_LABEL: Record<string, string> = {
405
+ host_fs_denied: 'Not allowed on that device yet — ask there (tray → Allow a folder…, or neuralis device grant); it serves once allowed',
406
+ };
407
+
408
+ /** A device's refusal of one file, by its fixed code; `undefined` for anything that is not a code. */
409
+ export function deviceRefusalLabel(reason: unknown): string | undefined {
410
+ if (typeof reason !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(reason)) return undefined;
411
+ return DEVICE_REFUSAL_LABEL[reason] ?? `The device refused this file (${reason})`;
412
+ }
413
+
414
+ /** The fixed codes a refused sync persists as `lastSyncResult.error` (a device's refusal codes too, by the map above). */
415
+ const SYNC_REFUSAL_LABEL: Record<string, string> = {
416
+ root_unreachable: 'Sync stopped — the source folder cannot be reached (unmounted, renamed or offline). Nothing was removed from the index.',
417
+ root_empty: 'Sync stopped — the source folder is empty, but the index still holds its files. Nothing was removed. Choose Sync anyway if the folder really is empty.',
418
+ };
419
+
420
+ /** A sync error for display: the label of a fixed refusal code, any other text unchanged. */
421
+ export function syncErrorLabel(error: string): string {
422
+ return SYNC_REFUSAL_LABEL[error] ?? DEVICE_REFUSAL_LABEL[error] ?? error;
423
+ }
424
+
425
+ /**
426
+ * Why a source that lists nothing is UNAVAILABLE rather than empty: its live
427
+ * state, else the code its walk, count or last sync stopped on (`last_error` —
428
+ * a failed walk or count never sets `sync_status`); `undefined` = truly empty.
429
+ */
430
+ export function sourceUnavailableLabel(info: SourceInfo): string | undefined {
431
+ if (info.live === false) return sourceLiveReasonLabel(info.live_reason, { kind: info.source_kind, notReady: info.live_not_ready });
432
+ return info.last_error ? syncErrorLabel(info.last_error) : undefined;
433
+ }
434
+
343
435
  export function resolveSourceToneTitle(info: SourceInfo): string | null {
344
436
  if (info.enabled === false) return 'Source disabled';
345
437
  if (info.enabled === 'auto' && info.enabled_effective === false) return 'Source auto — currently stopped';
346
- if (info.live === false) return sourceLiveReasonLabel(info.live_reason);
347
- if (info.sync_status === 'error') return info.last_error ?? 'Sync error';
438
+ if (info.live === false) return sourceLiveReasonLabel(info.live_reason, { kind: info.source_kind, notReady: info.live_not_ready });
439
+ if (info.sync_status === 'error') return info.last_error ? syncErrorLabel(info.last_error) : 'Sync error';
348
440
  if (info.sync_status === 'paused') return 'Sync paused';
349
441
  if (info.sync_status === 'active') return 'Sync active';
442
+ // A failed walk or count leaves `sync_status` alone: neutral tone, the code as the title.
443
+ if (info.last_error) return syncErrorLabel(info.last_error);
350
444
  return null;
351
445
  }
352
446
 
@@ -65,6 +65,7 @@ Two guards make this safe against the races a plain re-list would lose:
65
65
  - **`pendingSourceSuggestion` is a HANDOFF, not state.** The tree's Discover dock cannot call into `SourcesPanel` — the panel is a conditional mount and does not exist at click time — so the pick travels through the store and the panel consumes it on mount, clearing it in the same tick. It is deliberately OUT of the persist allowlist (a reload that re-opened a prefilled Add-Source form by itself is the ghost class this store already fought) and `loadList`'s context reset clears it beside `workspacePanels`, so a pick can never strand across an agent/project switch and prefill a root the new context does not have.
66
66
  - `loadedFolders[uri]` means "this folder HAS been enumerated". Only a successful listing sets it. Marking a source loaded that was never listed (the old `loadList` did this for every source, connector-failed ones included) lets a later absence pass prune everything under it. `false` is a third state, STALE: enumerated once, then taken off screen by a collapse. Every truthiness reader treats it as not loaded; `ensureFolderLoaded` on a stale folder runs ONE narrow authoritative pass over exactly that folder (`runSourceReconcile({ wide: false })`, absence applies) instead of an upsert-only listing, and that pass marks it `true` again; a stale folder put back on screen another way (`autoExpandToFile`, a nav link) is re-listed by the next wide pass. A narrow re-open pass whose listing FAILS leaves the folder stale, with only the source header's "may be out of date" hint; the next collapse and re-open retries it.
67
67
  - **Collapse and the bound.** `toggleFolder` collapsing a folder drops every expanded descendant from `expandedFolders` and marks every enumerated folder at or under it stale — so no reconcile keeps listing what nobody can see. The expanded set is capped at `EXPANDED_FOLDERS_CAP` (`listSlice.ts`); `expandPatch` moves each newly expanded folder to most-recent and `boundExpandedFolders` drops the oldest LEAF (never an ancestor of a kept folder, never a source root), marking it stale like a collapse. `autoExpandToFile` is the writer that needs it: it adds a folder chain for every pending write an agent makes. The persisted projection and a rehydrated legacy payload are bounded the same way.
68
+ - **Per-source refresh:** It works on a collapsed source too — an explicit click is a user intent, unlike the automatic event-driven loads, which never list a collapsed source — and it opens the branch so the result is visible.
68
69
 
69
70
  ## Tests
70
71
 
@@ -4,7 +4,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
4
4
  import { createStore, type StoreApi as ZustandStoreApi } from 'zustand/vanilla';
5
5
  import type { ClientStateResetScope } from '@neuralis/package-system/contracts';
6
6
  import { createPackageApi, type PackageApi } from '@neuralis/package-system/client';
7
- import type { EditorTabMeta, FileNode, FileSystemState, FSWriteResponse, PersistedFilesContext, PersistedFilesWindow, SidebarView, WorkspacePanelId } from '../domain/types';
7
+ import type { EditorTabMeta, FileNode, FileSystemState, FSWriteResponse, PersistedFilesContext, PersistedFilesWindow, SidebarView, SourceInfo, WorkspacePanelId } from '../domain/types';
8
8
  export type { SidebarView, WorkspacePanelId } from '../domain/types';
9
9
  import { countPendingChanges, readFiles, uploadFile, writeFile, writeRefusalMessage } from '../api';
10
10
  import {
@@ -21,8 +21,9 @@ import {
21
21
  sourceRootUri,
22
22
  uriAncestors,
23
23
  parseFilePrev,
24
+ readFailureMessage,
24
25
  } from './helpers';
25
- import { resolveList, type DiscoverableSourceSuggestion } from '../api';
26
+ import { listDiscoverableSources, resolveList, type DiscoverableSourceSuggestion } from '../api';
26
27
  import { searchSliceInitial, createSearchSlice, type SearchSliceActions } from './searchSlice';
27
28
  import { boundExpandedFolders, contextResetPatch, createListSlice, persistedContext, sliceFromPersisted, type ListSliceActions } from './listSlice';
28
29
  import { gitSliceInitial, createGitSlice, type GitSliceState, type GitSliceActions } from './gitSlice';
@@ -185,6 +186,38 @@ function repairParentUri(next: Record<string, any>, rawUri: string, prev: any):
185
186
  return fixed;
186
187
  }
187
188
 
189
+ /**
190
+ * A folder listing carries its source's CURRENT state. The tree's empty row and
191
+ * header read `state.sources`, which otherwise only the sources-only listing
192
+ * writes — so a Refresh that found the device gone would show "No files" until
193
+ * the next background pass. Only the live/sync fields move; counts and every
194
+ * other field stay with the listing that asked for them.
195
+ */
196
+ export function mergeListedSourceState(current: SourceInfo[], res: { sources?: unknown }): SourceInfo[] {
197
+ const listed = Array.isArray(res.sources) ? (res.sources as SourceInfo[]) : [];
198
+ if (listed.length === 0 || current.length === 0) return current;
199
+ let out = current;
200
+ for (const fresh of listed) {
201
+ const i = current.findIndex((s) => s.uri_root === fresh.uri_root);
202
+ if (i < 0) continue;
203
+ const old = current[i];
204
+ const patch: SourceInfo = {
205
+ ...old,
206
+ live: fresh.live,
207
+ live_reason: fresh.live_reason,
208
+ live_not_ready: fresh.live_not_ready,
209
+ sync_status: fresh.sync_status,
210
+ last_error: fresh.last_error,
211
+ };
212
+ if (patch.live === old.live && patch.live_reason === old.live_reason
213
+ && patch.live_not_ready === old.live_not_ready && patch.sync_status === old.sync_status
214
+ && patch.last_error === old.last_error) continue;
215
+ if (out === current) out = current.slice();
216
+ out[i] = patch;
217
+ }
218
+ return out;
219
+ }
220
+
188
221
  function applyListedNodes(
189
222
  next: Record<string, any>,
190
223
  res: { folders?: unknown[]; files?: unknown[] },
@@ -476,6 +509,10 @@ async function runReconcilePass(
476
509
  const pruned: string[] = [];
477
510
  let anyAuthoritative = false;
478
511
  let incomplete = false;
512
+ const listedSources = (st: any): SourceInfo[] => listed.reduce(
513
+ (acc: SourceInfo[], entry) => (entry.ok && entry.res ? mergeListedSourceState(acc, entry.res) : acc),
514
+ st.sources ?? [],
515
+ );
479
516
 
480
517
  for (const entry of listed) {
481
518
  if (!entry.ok || !entry.res) { incomplete = true; continue; }
@@ -504,7 +541,7 @@ async function runReconcilePass(
504
541
  if (!anyAuthoritative) {
505
542
  // Every listing failed or was truncated → upsert-only, no absence pass.
506
543
  return withPendingDelta(
507
- { files: evictColdSubtrees(next, state.expandedFolders, state.openFiles), dirtySources, loadedFolders },
544
+ { files: evictColdSubtrees(next, state.expandedFolders, state.openFiles), dirtySources, loadedFolders, sources: listedSources(state) },
508
545
  state.pendingReviewPaths,
509
546
  pendingDelta,
510
547
  );
@@ -605,6 +642,7 @@ async function runReconcilePass(
605
642
  out.fileContentByPath = cbp; out.fileBaseContentByPath = bbp;
606
643
  out.fileErrorByPath = ebp; out.filePrevByPath = pbp;
607
644
  }
645
+ out.sources = listedSources(state);
608
646
  return withPendingDelta(out, state.pendingReviewPaths, pendingDelta);
609
647
  });
610
648
  }
@@ -810,7 +848,7 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
810
848
  force?: boolean;
811
849
  }) => Promise<void>;
812
850
  /**
813
- * brisk-kingfisher — reconcile the open (expanded) folders of ONE source against
851
+ * Reconcile the open (expanded) folders of ONE source against
814
852
  * the connector's CURRENT truth, in response to an EXTERNAL file change SSE event.
815
853
  * Re-lists each expanded+loaded folder under the source and, per direct child
816
854
  * absent from the fresh (non-truncated) listing: prunes an `unindexed` external-
@@ -932,6 +970,22 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
932
970
  */
933
971
  pendingSourceSuggestion: DiscoverableSourceSuggestion | null;
934
972
  setPendingSourceSuggestion: (suggestion: DiscoverableSourceSuggestion | null) => void;
973
+ /**
974
+ * The attachable-but-unattached source suggestions (`GET sources/discoverable`)
975
+ * for ONE (agent, project) pair — the ONE owner both consumers read: the
976
+ * tree's Discover dock (`useDiscoverableSources`) and the Sources panel (its
977
+ * list + the folder picker's Suggested row). `key` names the pair the answer
978
+ * belongs to; a reader on another pair sees nothing. Not persisted.
979
+ */
980
+ discoverable: { key: string; suggestions: readonly DiscoverableSourceSuggestion[] } | null;
981
+ /**
982
+ * Fetch the suggestions for a pair: an answer already held for it (or one in
983
+ * flight) is reused, so opening Files costs ONE request whoever asks first;
984
+ * `fresh` re-reads (the panel's Refresh, after a mutation). Fail-soft: a
985
+ * failure holds an empty list for the pair (no retry loop) until the next
986
+ * fresh read or a context change.
987
+ */
988
+ loadDiscoverable: (ctx: { agentId: string; projectId?: string }, opts?: { fresh?: boolean }) => Promise<readonly DiscoverableSourceSuggestion[]>;
935
989
  /**
936
990
  * Is the Sources panel's Vector Health section open? In the STORE so a
937
991
  * notification's `open-vector-health` nav can open it while the panel is not
@@ -1147,6 +1201,11 @@ export type FilesystemStoreInit = {
1147
1201
 
1148
1202
  export type FilesystemStoreApi = ZustandStoreApi<FilesystemStore>;
1149
1203
 
1204
+ /** The (agent, project) pair a discoverable answer belongs to. */
1205
+ export function discoverableKey(agentId: string, projectId: string | undefined): string {
1206
+ return `${agentId}\n${projectId ?? ''}`;
1207
+ }
1208
+
1150
1209
  /**
1151
1210
  * ONE Files store per window: a live view that persists nothing itself — the
1152
1211
  * ONE writer of `neuralis.filesystem.tabs` is `filesPersistence` below, which a
@@ -1158,6 +1217,8 @@ export type FilesystemStoreApi = ZustandStoreApi<FilesystemStore>;
1158
1217
  export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStoreApi {
1159
1218
  const fences = createStoreFences();
1160
1219
  const client = init.client;
1220
+ // The one discoverable read in flight for this window (`loadDiscoverable`).
1221
+ let discoverableInFlight: { key: string; promise: Promise<readonly DiscoverableSourceSuggestion[]> } | null = null;
1161
1222
  return createStore<FilesystemStore>()((set, get) => ({
1162
1223
  windowId: init.windowId ?? null,
1163
1224
  client,
@@ -1191,6 +1252,7 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
1191
1252
  workspacePanels: { upload: false, sources: false, packages: false, pending: false },
1192
1253
  activeWorkspaceTab: 'file',
1193
1254
  pendingSourceSuggestion: null,
1255
+ discoverable: null,
1194
1256
  vectorHealthOpen: false,
1195
1257
  diffViewMode: init.diffViewMode ?? 'auto',
1196
1258
  pendingPanelFilter: {},
@@ -1263,6 +1325,24 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
1263
1325
  return { workspacePanels: nextPanels, activeWorkspaceTab };
1264
1326
  }),
1265
1327
  setPendingSourceSuggestion: (suggestion) => set(() => ({ pendingSourceSuggestion: suggestion })),
1328
+ loadDiscoverable: (ctx, opts) => {
1329
+ const key = discoverableKey(ctx.agentId, ctx.projectId);
1330
+ const held = get().discoverable;
1331
+ if (!opts?.fresh && held?.key === key) return Promise.resolve(held.suggestions);
1332
+ if (!opts?.fresh && discoverableInFlight?.key === key) return discoverableInFlight.promise;
1333
+ const promise = listDiscoverableSources({ agentId: ctx.agentId, ...(ctx.projectId ? { projectId: ctx.projectId } : {}) }, client)
1334
+ .then((res) => res.suggestions ?? [], () => [] as DiscoverableSourceSuggestion[])
1335
+ .then((suggestions) => {
1336
+ // Only the newest read for a pair writes it; a superseded one answers its caller alone.
1337
+ if (discoverableInFlight?.promise === promise) {
1338
+ discoverableInFlight = null;
1339
+ set(() => ({ discoverable: { key, suggestions } }));
1340
+ }
1341
+ return suggestions as readonly DiscoverableSourceSuggestion[];
1342
+ });
1343
+ discoverableInFlight = { key, promise };
1344
+ return promise;
1345
+ },
1266
1346
  setVectorHealthOpen: (open) => set(() => ({ vectorHealthOpen: open })),
1267
1347
  setActiveWorkspaceTab: (tab) => set(() => ({ activeWorkspaceTab: tab })),
1268
1348
  setDiffViewMode: (mode) => set(() => ({ diffViewMode: mode })),
@@ -1388,6 +1468,7 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
1388
1468
  // to list must not have its (never-listed) children pruned.
1389
1469
  loadedFolders: { ...(state.loadedFolders || {}), [uri]: true },
1390
1470
  hydrationState: { ...(state.hydrationState || {}), [uri]: 'ready' as const },
1471
+ sources: mergeListedSourceState(state.sources ?? [], res),
1391
1472
  },
1392
1473
  state.pendingReviewPaths,
1393
1474
  pendingDelta,
@@ -2169,7 +2250,7 @@ export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStor
2169
2250
  if (files.length > 0) {
2170
2251
  const f = files[0];
2171
2252
  const content = extractFileContent(f);
2172
- const error = typeof f.error === 'string' ? f.error : undefined;
2253
+ const error = readFailureMessage(f);
2173
2254
  // The file is GONE, not merely unreadable. This matters because the open-tab
2174
2255
  // shape is PERSISTED: a tab pointing at a deleted file re-materialises its
2175
2256
  // node (and its whole folder chain) on every page load, producing a tree row
@@ -198,6 +198,11 @@ export function createGitSlice(
198
198
  ...(repoRel ? { repo: repoRel } : {}),
199
199
  }, get().client);
200
200
  if (mySeq !== gitStatusSeq) return;
201
+ if (res?.ok === false) {
202
+ // A checkout git could not read is never shown as "No changes".
203
+ set(() => ({ gitStatus: null, gitTotals: null, gitLoading: false, gitError: res.error }));
204
+ return;
205
+ }
201
206
  // `totals` rides the SAME response as the rows it sums (the server
202
207
  // computes it after its read filter and cap), so the header can never
203
208
  // show a number the list below contradicts.
@@ -1,4 +1,5 @@
1
1
  import type { FileNode, FilePrev } from '../domain/types';
2
+ import { deviceRefusalLabel, sourceLiveReasonLabel } from '../lib/treeVisualState';
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // URI helpers (lightweight, client-side — no dependency on brain-core/src/)
@@ -213,3 +214,19 @@ export function contextKey(
213
214
  ): string {
214
215
  return `${userId ?? ''}::${projectId ?? ''}::${agentId}`;
215
216
  }
217
+
218
+ /**
219
+ * One file's read failure as the Files UI says it: a device folder that could
220
+ * not serve names its state in the tree's own words (`source_unavailable` →
221
+ * the live-reason label, `device_refused` → the refusal code); any other
222
+ * failure keeps the server's text. `undefined` when the file read.
223
+ */
224
+ export function readFailureMessage(file: { error?: unknown; code?: unknown; reason?: unknown; not_ready?: unknown }): string | undefined {
225
+ if (typeof file.error !== 'string') return undefined;
226
+ // `source_unavailable` is a device folder's answer only (the `host` connector's device transport).
227
+ if (file.code === 'source_unavailable' && typeof file.reason === 'string') {
228
+ return sourceLiveReasonLabel(file.reason, { kind: 'host', notReady: Array.isArray(file.not_ready) ? file.not_ready : null });
229
+ }
230
+ if (file.code === 'device_refused') return deviceRefusalLabel(file.reason) ?? file.error;
231
+ return file.error;
232
+ }
@@ -436,9 +436,17 @@ export function createListSlice(
436
436
  }
437
437
  }
438
438
 
439
+ // An expanded folder of a source this listing no longer names (deleted, or no
440
+ // longer visible) would be listed on every load and refused: one pass drops it,
441
+ // and the window's persisted projection follows.
442
+ const listedSources = new Set(sources.map((s) => sourceFromUri(s.uri_root)));
443
+ const expanded = state.expandedFolders as Set<string>;
444
+ const keptExpanded = sources.length > 0 ? [...expanded].filter((uri) => listedSources.has(sourceFromUri(uri))) : [];
445
+
439
446
  return {
440
447
  files: next,
441
448
  sources,
449
+ ...(sources.length > 0 && keptExpanded.length !== expanded.size ? { expandedFolders: new Set(keptExpanded) } : {}),
442
450
  sourcesLoading: false,
443
451
  // A listing that answered clears the previous failure's banner.
444
452
  snapshotError: undefined,