@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
@@ -6,7 +6,10 @@
6
6
  * a `.git` stat pre-gate, a FRESH `GitRunner` per cache miss (never reuse — the
7
7
  * `#assertRepoInsideSource` memoised-rejection trap), a short TTL cache, and
8
8
  * fail-soft `null` on any git problem so a slow/broken repo never blocks the list
9
- * or panel path.
9
+ * or panel path — save ONE: a paired device that refuses the spawn
10
+ * (`GitDeviceUnavailableError`) is thrown by every panel reader for the route
11
+ * to name, never folded into a clean repo's empty answer (the list-path
12
+ * summary never spawns on a device, so it never meets one).
10
13
  *
11
14
  * THREE readers, each with its own cost/scope:
12
15
  * - `getSourceGitStatus` — the WHOLE-repo change-set (staged / unstaged /
@@ -31,7 +34,7 @@
31
34
  * listing and the watcher expansion — consume the discovered set WITHOUT ever
32
35
  * passing the route validation ladder. A symlinked child pointing at a foreign
33
36
  * repo would therefore (1) leak the foreign repo's existence + branch in the
34
- * listing and (2) plant a host-side chokidar watcher on a foreign `.git`. So
37
+ * listing and (2) plant a host-side file watcher on a foreign `.git`. So
35
38
  * every candidate child must pass `realpath(child)` containment under
36
39
  * `realpath(sourceRoot)` BEFORE inclusion — at DISCOVERY itself, not only in
37
40
  * the route ladder — and the `containsRepos` probe applies the SAME predicate
@@ -44,7 +47,8 @@
44
47
  import { opendir, readFile, realpath, stat } from 'node:fs/promises';
45
48
  import path from 'node:path';
46
49
  import { hasFeature } from '@neuralis/package-system/access';
47
- import { GitRunner, GitOutputLimitError, EMPTY_TREE_SHA, } from './GitRunner.js';
50
+ import { childPlace, placeKey, placePath, readDeviceHeadBranch, readDeviceSmallFile, toGitPlace, } from './gitPlace.js';
51
+ import { GitRunner, GitDeviceUnavailableError, GitOutputLimitError, EMPTY_TREE_SHA, } from './GitRunner.js';
48
52
  import { xyCode } from './sourceGitStatus.js';
49
53
  import { resolvePermissions } from '../domain/permissionResolver.js';
50
54
  import { getBrainConfigNumber } from '../config/env.js';
@@ -74,18 +78,18 @@ const GIT_DISCOVERY_MAX_REPOS = 25;
74
78
  * demand; a repo too slow to answer within the cap fails soft to `null` (empty
75
79
  * panel), never blocking the request.
76
80
  */
77
- const GIT_CONTROL_STATUS_TIMEOUT_MS = 5000;
81
+ const GIT_CONTROL_STATUS_TIMEOUT_MS = 15000;
78
82
  /**
79
83
  * Inactivity cap for the HEAVY on-demand readers — the per-file diff, the
80
84
  * commit DAG, and blob content at a rev (`show`) — so a 2 MB diff or a
81
- * 2000-commit log on a slow volume doesn't die at the 5 s status cap. NOTE:
82
- * both this and simple-git's `timeout:{block}` are INACTIVITY timers — they
83
- * reset on every output chunk, capping SILENCE, not wall-clock. A
84
- * continuously-STREAMING pathological command trips neither; that is stopped
85
- * by the engine's output accumulation bound (`GIT_MAX_OUTPUT_BYTES` in
86
- * `GitRunner.#spawnGit`, MINOR-6).
85
+ * 2000-commit log on a slow volume doesn't die at the status cap. NOTE:
86
+ * locally this is an INACTIVITY timer — it resets on every output chunk,
87
+ * capping SILENCE, not wall-clock (on a device the broker's timer is
88
+ * wall-clock). A continuously-STREAMING pathological command never trips it;
89
+ * that is stopped by the engine's output accumulation bound
90
+ * (`GIT_MAX_OUTPUT_BYTES`, on both transports, MINOR-6).
87
91
  */
88
- const GIT_CONTROL_HEAVY_TIMEOUT_MS = 15000;
92
+ const GIT_CONTROL_HEAVY_TIMEOUT_MS = 60000;
89
93
  /** Max blob bytes `getSourceGitShow` serves — matches the route's 2 MB UI diff cap. */
90
94
  const GIT_SHOW_MAX_BLOB_BYTES = 2 * 1024 * 1024;
91
95
  const statusCache = new Map();
@@ -96,6 +100,23 @@ const summaryCache = new Map();
96
100
  // is shared by the summary's containsRepos/repoNames derivation, the route, and
97
101
  // the watcher expansion.
98
102
  const discoveryCache = new Map();
103
+ /**
104
+ * A device that could not run a read (offline, devices off, the owner's
105
+ * "others" level, its ceiling) is a REFUSAL the route names — never the empty
106
+ * answer of a clean repo. Every other read failure stays fail-soft.
107
+ */
108
+ function rethrowDeviceRefusal(error) {
109
+ if (error instanceof GitDeviceUnavailableError)
110
+ throw error;
111
+ }
112
+ /**
113
+ * The cache key of a read that SPAWNS git: on a device the caller's class
114
+ * rides it, so the owner's answer is never served to another member, whom
115
+ * the device judges again at every spawn (its per-folder "others" level).
116
+ */
117
+ function spawnKey(place) {
118
+ return place.device && place.device.owner !== true ? `${placeKey(place)}\0other` : placeKey(place);
119
+ }
99
120
  async function withCache(cache, key, ttlMs, compute) {
100
121
  const now = Date.now();
101
122
  const hit = cache.get(key);
@@ -105,16 +126,65 @@ async function withCache(cache, key, ttlMs, compute) {
105
126
  cache.set(key, { result, expiresAt: now + ttlMs });
106
127
  return result;
107
128
  }
108
- /** True when the source root has its OWN `.git` (dir OR linked-worktree file). */
109
- async function hasDotGit(resolvedRoot) {
129
+ /** True when the source root has its OWN `.git` (dir OR linked-worktree file) — on its own machine. */
130
+ async function hasDotGit(place) {
131
+ const at = placePath(place).join(place.root, '.git');
110
132
  try {
111
- await stat(path.join(resolvedRoot, '.git'));
133
+ if (place.device)
134
+ return (await place.device.transport.stat(at)) !== null;
135
+ await stat(at);
112
136
  return true;
113
137
  }
114
138
  catch {
115
139
  return false;
116
140
  }
117
141
  }
142
+ /**
143
+ * The device half of the depth-1 scan. The device's listing never names a
144
+ * symlink (the transport filters them), so every candidate child is a real
145
+ * directory under the root — containment holds by construction, with no
146
+ * `realpath` this process could not run there. A `.git` found by `stat` is
147
+ * admitted only when the child's own listing shows it as a real entry: `stat`
148
+ * follows links (inside the device's ceiling), the listing does not, so a
149
+ * symlinked `.git` onto a foreign repo drops exactly as the local `realpath`
150
+ * tail drops it. Cost: one listing of the root, one `stat` per examined child
151
+ * directory, one listing per find (≤ `limit`), at most 8 RPCs in flight.
152
+ */
153
+ async function scanDeviceDepth1RepoChildren(place, limit, maxDirents) {
154
+ const seat = place.device;
155
+ if (!seat)
156
+ return [];
157
+ const p = placePath(place);
158
+ let entries;
159
+ try {
160
+ entries = await seat.transport.list(place.root);
161
+ }
162
+ catch {
163
+ return [];
164
+ }
165
+ const queue = entries.slice(0, maxDirents).filter((e) => e.isDirectory).map((e) => e.name);
166
+ const workers = Math.min(8, queue.length);
167
+ const found = [];
168
+ const worker = async () => {
169
+ for (let name = queue.shift(); name !== undefined && found.length < limit; name = queue.shift()) {
170
+ const child = p.join(place.root, name);
171
+ try {
172
+ if ((await seat.transport.stat(p.join(child, '.git'))) === null)
173
+ continue;
174
+ const inner = await seat.transport.list(child);
175
+ if (!inner.some((e) => e.name === '.git'))
176
+ continue; // a symlinked `.git` — never admitted
177
+ }
178
+ catch {
179
+ continue;
180
+ }
181
+ if (found.length < limit)
182
+ found.push({ rel: name, name, root: child, place: childPlace(place, name) });
183
+ }
184
+ };
185
+ await Promise.all(Array.from({ length: workers }, () => worker()));
186
+ return found;
187
+ }
118
188
  /**
119
189
  * The ONE bounded depth-1 scan the discovery and the `containsRepos` probe
120
190
  * share (E1). STREAMING `fs.opendir` iteration — never a full `readdir`
@@ -133,7 +203,7 @@ async function hasDotGit(resolvedRoot) {
133
203
  * containment too: step 3 STAT-FOLLOWS the `.git` entry itself, so a
134
204
  * contained child whose `.git` is a SYMLINK onto a foreign repo would
135
205
  * otherwise pass — re-opening exactly the exposure this filter closes
136
- * (foreign chokidar watch, foreign listing/history). A regular `.git`
206
+ * (foreign file watch, foreign listing/history). A regular `.git`
137
207
  * dir or linked-worktree FILE realpaths inside the child (NIT-4's
138
208
  * `.git`-file children stay admitted); only an escaping link drops.
139
209
  * Residual (G2.5): a `.git` FILE whose `gitdir:` line points outside —
@@ -142,7 +212,11 @@ async function hasDotGit(resolvedRoot) {
142
212
  * Fail-soft throughout: an unreadable root yields `[]`, a mid-stream error
143
213
  * yields the entries found so far — a broken volume never breaks the caller.
144
214
  */
145
- async function scanDepth1RepoChildren(resolvedRoot, limit) {
215
+ async function scanDepth1RepoChildren(place, limit) {
216
+ const maxDirents = getBrainConfigNumber('brainGitDiscoveryMaxDirents', undefined, GIT_DISCOVERY_MAX_DIRENTS);
217
+ if (place.device)
218
+ return scanDeviceDepth1RepoChildren(place, limit, maxDirents);
219
+ const resolvedRoot = place.root;
146
220
  let realRoot;
147
221
  try {
148
222
  realRoot = await realpath(resolvedRoot);
@@ -164,7 +238,7 @@ async function scanDepth1RepoChildren(resolvedRoot, limit) {
164
238
  // Breaking out of for-await invokes the iterator's return() → the Dir
165
239
  // handle closes itself; no manual close (a second close would throw).
166
240
  for await (const dirent of dir) {
167
- if (examined >= getBrainConfigNumber('brainGitDiscoveryMaxDirents', undefined, GIT_DISCOVERY_MAX_DIRENTS))
241
+ if (examined >= maxDirents)
168
242
  break;
169
243
  examined += 1;
170
244
  if (!dirent.isDirectory() && !dirent.isSymbolicLink())
@@ -179,7 +253,7 @@ async function scanDepth1RepoChildren(resolvedRoot, limit) {
179
253
  }
180
254
  if (real !== realRoot && !real.startsWith(rootPrefix))
181
255
  continue; // MAJOR-A
182
- if (!(await hasDotGit(child)))
256
+ if (!(await hasDotGit({ root: child })))
183
257
  continue;
184
258
  // Step 4 (MAJOR-A tail): contain the `.git` TARGET as well — hasDotGit
185
259
  // stat-followed it, so a symlinked `.git` onto a foreign repo passed.
@@ -192,7 +266,7 @@ async function scanDepth1RepoChildren(resolvedRoot, limit) {
192
266
  catch {
193
267
  continue; // raced away between the stat and the realpath — fail-soft skip
194
268
  }
195
- found.push({ rel: dirent.name, name: dirent.name, root: child });
269
+ found.push({ rel: dirent.name, name: dirent.name, root: child, place: { root: child } });
196
270
  if (found.length >= limit)
197
271
  break;
198
272
  }
@@ -217,18 +291,19 @@ async function scanDepth1RepoChildren(resolvedRoot, limit) {
217
291
  * `invalidateSourceGitCache(sourceRoot)` / `invalidateSourceGitDiscovery`
218
292
  * drop the entry.
219
293
  */
220
- export function discoverSourceRepoRoots(resolvedRoot) {
221
- return withCache(discoveryCache, resolvedRoot, GIT_DISCOVERY_CACHE_TTL_MS, () => computeRepoRootsUncached(resolvedRoot));
294
+ export function discoverSourceRepoRoots(target) {
295
+ const place = toGitPlace(target);
296
+ return withCache(discoveryCache, placeKey(place), GIT_DISCOVERY_CACHE_TTL_MS, () => computeRepoRootsUncached(place));
222
297
  }
223
- async function computeRepoRootsUncached(resolvedRoot) {
224
- if (await hasDotGit(resolvedRoot)) {
225
- return [{ rel: '', name: (await repoNameFromOrigin(resolvedRoot)) ?? repoNameFromRoot(resolvedRoot) ?? '', root: resolvedRoot }];
298
+ async function computeRepoRootsUncached(place) {
299
+ if (await hasDotGit(place)) {
300
+ return [{ rel: '', name: (await repoNameFromOrigin(place)) ?? repoNameFromRoot(place) ?? '', root: place.root, place }];
226
301
  }
227
- const found = await scanDepth1RepoChildren(resolvedRoot, getBrainConfigNumber('brainGitDiscoveryMaxRepos', undefined, GIT_DISCOVERY_MAX_REPOS));
302
+ const found = await scanDepth1RepoChildren(place, getBrainConfigNumber('brainGitDiscoveryMaxRepos', undefined, GIT_DISCOVERY_MAX_REPOS));
228
303
  // `name` is the DISPLAY name (origin basename, else the dir), the summary's order;
229
304
  // `rel` stays the dir and is the only one a path gate may read. The cap grabs the
230
305
  // FIRST ≤25 in dirent order; the REPORT is name-sorted (codepoint, locale-free).
231
- const named = await Promise.all(found.map(async (r) => ({ ...r, name: (await repoNameFromOrigin(r.root)) ?? r.name })));
306
+ const named = await Promise.all(found.map(async (r) => ({ ...r, name: (await repoNameFromOrigin(r.place)) ?? r.name })));
232
307
  return named.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
233
308
  }
234
309
  /**
@@ -265,23 +340,31 @@ export function filterVisibleRepoNames(repoNames, permissions, source, createdBy
265
340
  * origin (fall back to the dir basename), the config is unreadable (a linked
266
341
  * worktree's `.git` is a FILE, no sibling config), or parsing yields nothing.
267
342
  */
268
- export async function repoNameFromOrigin(resolvedRoot) {
269
- const configPath = path.join(resolvedRoot, '.git', 'config');
343
+ export async function repoNameFromOrigin(target) {
344
+ const place = toGitPlace(target);
345
+ const configPath = placePath(place).join(place.root, '.git', 'config');
270
346
  let text;
271
- try {
272
- // Bound the READ, not a post-read slice: a project member can write the
273
- // shared source's `.git/config`, and this runs on the summary hot path — a
274
- // pathologically large config must never force a full-file allocation on a
275
- // co-member's uncached summary. `.git/config` is normally a few hundred
276
- // bytes; anything past 256 KB is not a real config → skip (basename fallback).
277
- const info = await stat(configPath);
278
- if (info.size > 256 * 1024)
347
+ if (place.device) {
348
+ const read = await readDeviceSmallFile(place.device, configPath, 256 * 1024);
349
+ if (read === null)
279
350
  return undefined;
280
- text = await readFile(configPath, 'utf8');
281
- }
282
- catch {
283
- return undefined;
284
- }
351
+ text = read;
352
+ }
353
+ else
354
+ try {
355
+ // Bound the READ, not a post-read slice: a project member can write the
356
+ // shared source's `.git/config`, and this runs on the summary hot path — a
357
+ // pathologically large config must never force a full-file allocation on a
358
+ // co-member's uncached summary. `.git/config` is normally a few hundred
359
+ // bytes; anything past 256 KB is not a real config → skip (basename fallback).
360
+ const info = await stat(configPath);
361
+ if (info.size > 256 * 1024)
362
+ return undefined;
363
+ text = await readFile(configPath, 'utf8');
364
+ }
365
+ catch {
366
+ return undefined;
367
+ }
285
368
  let inOrigin = false;
286
369
  for (const raw of text.split('\n')) {
287
370
  const line = raw.trim();
@@ -313,17 +396,18 @@ export async function repoNameFromOrigin(resolvedRoot) {
313
396
  * user action (panel open / repo select), NEVER on the `fs_list` hot path (use
314
397
  * `getSourceGitSummary` there).
315
398
  */
316
- export function getSourceGitStatus(resolvedRoot, gitBin) {
317
- return withCache(statusCache, resolvedRoot, GIT_CONTROL_CACHE_TTL_MS, () => computeStatusUncached(resolvedRoot, gitBin));
399
+ export function getSourceGitStatus(target, gitBin) {
400
+ const place = toGitPlace(target);
401
+ return withCache(statusCache, spawnKey(place), GIT_CONTROL_CACHE_TTL_MS, () => computeStatusUncached(place, gitBin));
318
402
  }
319
- async function computeStatusUncached(resolvedRoot, gitBin) {
320
- if (!(await hasDotGit(resolvedRoot)))
403
+ async function computeStatusUncached(place, gitBin) {
404
+ if (!(await hasDotGit(place)))
321
405
  return null;
322
406
  let runner;
323
407
  try {
324
408
  // Fresh runner per miss — NEVER cache a runner (memoised-rejection trap).
325
409
  runner = await GitRunner.create({
326
- containerRoot: resolvedRoot,
410
+ root: place,
327
411
  gitBin,
328
412
  timeoutMs: getBrainConfigNumber('brainGitPanelTimeoutMs', undefined, GIT_CONTROL_STATUS_TIMEOUT_MS),
329
413
  });
@@ -384,7 +468,8 @@ async function computeStatusUncached(resolvedRoot, gitBin) {
384
468
  untracked,
385
469
  };
386
470
  }
387
- catch {
471
+ catch (error) {
472
+ rethrowDeviceRefusal(error);
388
473
  // GitNotARepoError / GitOutsideSourceError / GitTimeoutError / any → fail-soft.
389
474
  return null;
390
475
  }
@@ -409,13 +494,14 @@ async function computeStatusUncached(resolvedRoot, gitBin) {
409
494
  * yields an empty primary diff but is NOT untracked, so it correctly stays blank
410
495
  * (never fabricated as fully-added).
411
496
  */
412
- export async function getSourceGitDiff(resolvedRoot, repoRelPath, target, gitBin) {
413
- if (!(await hasDotGit(resolvedRoot)))
497
+ export async function getSourceGitDiff(at, repoRelPath, target, gitBin) {
498
+ const place = toGitPlace(at);
499
+ if (!(await hasDotGit(place)))
414
500
  return null;
415
501
  let runner;
416
502
  try {
417
503
  runner = await GitRunner.create({
418
- containerRoot: resolvedRoot,
504
+ root: place,
419
505
  gitBin,
420
506
  timeoutMs: getBrainConfigNumber('brainGitHeavyTimeoutMs', undefined, GIT_CONTROL_HEAVY_TIMEOUT_MS),
421
507
  });
@@ -445,6 +531,7 @@ export async function getSourceGitDiff(resolvedRoot, repoRelPath, target, gitBin
445
531
  return { ok: true, diff: primary };
446
532
  }
447
533
  catch (error) {
534
+ rethrowDeviceRefusal(error);
448
535
  if (error instanceof GitOutputLimitError)
449
536
  return { ok: false, code: 'too_large' };
450
537
  // GitNotARepoError / GitOutsideSourceError / GitArgumentError / GitTimeoutError / GitApplyError → fail-soft.
@@ -493,13 +580,14 @@ async function withNumstat(runner, files, from, to) {
493
580
  return { ...f, added: n.added, removed: n.removed, ...(n.binary ? { binary: true } : {}) };
494
581
  });
495
582
  }
496
- export async function getSourceGitCommitFiles(resolvedRoot, sha, gitBin) {
497
- if (!(await hasDotGit(resolvedRoot)))
583
+ export async function getSourceGitCommitFiles(target, sha, gitBin) {
584
+ const place = toGitPlace(target);
585
+ if (!(await hasDotGit(place)))
498
586
  return null;
499
587
  let runner;
500
588
  try {
501
589
  runner = await GitRunner.create({
502
- containerRoot: resolvedRoot,
590
+ root: place,
503
591
  gitBin,
504
592
  timeoutMs: getBrainConfigNumber('brainGitHeavyTimeoutMs', undefined, GIT_CONTROL_HEAVY_TIMEOUT_MS),
505
593
  });
@@ -512,7 +600,8 @@ export async function getSourceGitCommitFiles(resolvedRoot, sha, gitBin) {
512
600
  const files = await runner.commitFiles(sha, { hasParent });
513
601
  return await withNumstat(runner, files, hasParent ? `${sha}^` : EMPTY_TREE_SHA, sha);
514
602
  }
515
- catch {
603
+ catch (error) {
604
+ rethrowDeviceRefusal(error);
516
605
  // Unknown sha / GitNotARepoError / GitTimeoutError / GitOutputLimitError / any → fail-soft.
517
606
  return null;
518
607
  }
@@ -533,13 +622,14 @@ export async function getSourceGitCommitFiles(resolvedRoot, sha, gitBin) {
533
622
  * - non-repo / unborn HEAD / timeout / any git error → `null`.
534
623
  * ON DEMAND, UNcached, fresh runner, heavy timeout.
535
624
  */
536
- export async function getSourceGitOutgoing(resolvedRoot, gitBin) {
537
- if (!(await hasDotGit(resolvedRoot)))
625
+ export async function getSourceGitOutgoing(target, gitBin) {
626
+ const place = toGitPlace(target);
627
+ if (!(await hasDotGit(place)))
538
628
  return null;
539
629
  let runner;
540
630
  try {
541
631
  runner = await GitRunner.create({
542
- containerRoot: resolvedRoot,
632
+ root: place,
543
633
  gitBin,
544
634
  timeoutMs: getBrainConfigNumber('brainGitHeavyTimeoutMs', undefined, GIT_CONTROL_HEAVY_TIMEOUT_MS),
545
635
  });
@@ -556,17 +646,19 @@ export async function getSourceGitOutgoing(resolvedRoot, gitBin) {
556
646
  const files = await withNumstat(runner, await runner.treeDiffFiles(resolved.baseSha, resolved.headSha), resolved.baseSha, resolved.headSha);
557
647
  return { baseSha: resolved.baseSha, headSha: resolved.headSha, files };
558
648
  }
559
- catch {
649
+ catch (error) {
650
+ rethrowDeviceRefusal(error);
560
651
  return null;
561
652
  }
562
653
  }
563
- export async function getSourceGitLogStats(resolvedRoot, opts, gitBin) {
564
- if (!(await hasDotGit(resolvedRoot)))
654
+ export async function getSourceGitLogStats(target, opts, gitBin) {
655
+ const place = toGitPlace(target);
656
+ if (!(await hasDotGit(place)))
565
657
  return null;
566
658
  let runner;
567
659
  try {
568
660
  runner = await GitRunner.create({
569
- containerRoot: resolvedRoot,
661
+ root: place,
570
662
  gitBin,
571
663
  timeoutMs: getBrainConfigNumber('brainGitHeavyTimeoutMs', undefined, GIT_CONTROL_HEAVY_TIMEOUT_MS),
572
664
  });
@@ -579,7 +671,8 @@ export async function getSourceGitLogStats(resolvedRoot, opts, gitBin) {
579
671
  const outgoing = await runner.outgoingShas();
580
672
  return { byCommit, ...(outgoing ? { outgoing } : {}) };
581
673
  }
582
- catch {
674
+ catch (error) {
675
+ rethrowDeviceRefusal(error);
583
676
  // Includes an older git rejecting `--diff-merges` — fail-soft to no stats.
584
677
  return null;
585
678
  }
@@ -599,13 +692,14 @@ export async function getSourceGitLogStats(resolvedRoot, opts, gitBin) {
599
692
  * log rather than a separate route so the graph's page fetch and its
600
693
  * decorations can never disagree about the ref state they were read under.
601
694
  */
602
- export async function getSourceGitLog(resolvedRoot, opts, gitBin) {
603
- if (!(await hasDotGit(resolvedRoot)))
695
+ export async function getSourceGitLog(target, opts, gitBin) {
696
+ const place = toGitPlace(target);
697
+ if (!(await hasDotGit(place)))
604
698
  return null;
605
699
  let runner;
606
700
  try {
607
701
  runner = await GitRunner.create({
608
- containerRoot: resolvedRoot,
702
+ root: place,
609
703
  gitBin,
610
704
  timeoutMs: getBrainConfigNumber('brainGitHeavyTimeoutMs', undefined, GIT_CONTROL_HEAVY_TIMEOUT_MS),
611
705
  });
@@ -618,7 +712,8 @@ export async function getSourceGitLog(resolvedRoot, opts, gitBin) {
618
712
  const outgoing = await runner.outgoingShas();
619
713
  return { commits, ...(outgoing ? { outgoing } : {}) };
620
714
  }
621
- catch {
715
+ catch (error) {
716
+ rethrowDeviceRefusal(error);
622
717
  return null;
623
718
  }
624
719
  }
@@ -637,13 +732,14 @@ export async function getSourceGitLog(resolvedRoot, opts, gitBin) {
637
732
  * `null` (fail-soft) for a non-git source, a path missing at the rev, a
638
733
  * timeout, or any git error.
639
734
  */
640
- export async function getSourceGitShow(resolvedRoot, repoRelPath, rev, gitBin) {
641
- if (!(await hasDotGit(resolvedRoot)))
735
+ export async function getSourceGitShow(target, repoRelPath, rev, gitBin) {
736
+ const place = toGitPlace(target);
737
+ if (!(await hasDotGit(place)))
642
738
  return null;
643
739
  let runner;
644
740
  try {
645
741
  runner = await GitRunner.create({
646
- containerRoot: resolvedRoot,
742
+ root: place,
647
743
  gitBin,
648
744
  timeoutMs: getBrainConfigNumber('brainGitHeavyTimeoutMs', undefined, GIT_CONTROL_HEAVY_TIMEOUT_MS),
649
745
  });
@@ -663,7 +759,8 @@ export async function getSourceGitShow(resolvedRoot, repoRelPath, rev, gitBin) {
663
759
  return { ok: false, code: 'binary' };
664
760
  return { ok: true, content, path: repoRelPath, rev };
665
761
  }
666
- catch {
762
+ catch (error) {
763
+ rethrowDeviceRefusal(error);
667
764
  // GitNotARepoError / GitOutsideSourceError / GitArgumentError / GitTimeoutError /
668
765
  // GitApplyError (path missing at the rev) / GitOutputLimitError → fail-soft.
669
766
  return null;
@@ -675,7 +772,13 @@ export async function getSourceGitShow(resolvedRoot, repoRelPath, rev, gitBin) {
675
772
  * The `.git/HEAD` badge watcher (G2.2) calls this the instant an EXTERNAL git
676
773
  * op fires so the next panel/list read recomputes from the live repo instead
677
774
  * of serving up to `GIT_CONTROL_CACHE_TTL_MS` of stale state. The caches key
678
- * on the resolved root (see `withCache`), so a keyed delete is exact — and
775
+ * on the resolved root (see `withCache`; the two that spawn on a device —
776
+ * status and branches — also on the caller's class, `spawnKey`, and both are
777
+ * dropped; the summary stays on the root because on a device it is FILE reads
778
+ * of `.git/HEAD` + `.git/config`, the same answer for every caller, and the
779
+ * discovery is the raw repo-root SET every consumer filters per caller through
780
+ * `filterVisibleRepoNames` — a spawn added to either moves it to `spawnKey`),
781
+ * so a keyed delete is exact — and
679
782
  * that keying is what keeps the watcher's existing call correct for NESTED
680
783
  * roots (E1): a nested-root invalidation drops that repo's own summary/status
681
784
  * entries, while the discovery/probe entries (keyed by the SOURCE root) are
@@ -683,11 +786,14 @@ export async function getSourceGitShow(resolvedRoot, repoRelPath, rev, gitBin) {
683
786
  * that DO change the set (source config mutation) invalidate at the source
684
787
  * root, which sweeps discovery too via `invalidateSourceGitDiscovery`.
685
788
  */
686
- export function invalidateSourceGitCache(resolvedRoot) {
687
- statusCache.delete(resolvedRoot);
688
- branchCache.delete(resolvedRoot);
689
- summaryCache.delete(resolvedRoot);
690
- invalidateSourceGitDiscovery(resolvedRoot);
789
+ export function invalidateSourceGitCache(target) {
790
+ const key = placeKey(target);
791
+ for (const spawned of [key, `${key}\0other`]) {
792
+ statusCache.delete(spawned);
793
+ branchCache.delete(spawned);
794
+ }
795
+ summaryCache.delete(key);
796
+ discoveryCache.delete(key);
691
797
  }
692
798
  /**
693
799
  * Source-root-keyed drop of ONLY the E1 nested-checkout discovery entry (the
@@ -696,7 +802,7 @@ export function invalidateSourceGitCache(resolvedRoot) {
696
802
  * without also flushing the source's summary/status caches.
697
803
  */
698
804
  export function invalidateSourceGitDiscovery(sourceRoot) {
699
- discoveryCache.delete(sourceRoot);
805
+ discoveryCache.delete(placeKey(sourceRoot));
700
806
  }
701
807
  /**
702
808
  * The local branch list for the branch-switcher. On demand, TTL-cached per root.
@@ -704,18 +810,19 @@ export function invalidateSourceGitDiscovery(sourceRoot) {
704
810
  * `getSourceGitSummary` (enumerates every local branch) — the switcher fetches it
705
811
  * on demand, never the list path.
706
812
  */
707
- export function getSourceBranches(resolvedRoot, gitBin) {
708
- return withCache(branchCache, resolvedRoot, GIT_CONTROL_CACHE_TTL_MS, () => computeBranchesUncached(resolvedRoot, gitBin));
813
+ export function getSourceBranches(target, gitBin) {
814
+ const place = toGitPlace(target);
815
+ return withCache(branchCache, spawnKey(place), GIT_CONTROL_CACHE_TTL_MS, () => computeBranchesUncached(place, gitBin));
709
816
  }
710
- async function computeBranchesUncached(resolvedRoot, gitBin) {
711
- if (!(await hasDotGit(resolvedRoot)))
817
+ async function computeBranchesUncached(place, gitBin) {
818
+ if (!(await hasDotGit(place)))
712
819
  return null;
713
820
  let runner;
714
821
  try {
715
822
  // MINOR-9: the branch list carries the SAME 5 s cap as status — it was the
716
823
  // one panel reader built with NO timeout.
717
824
  runner = await GitRunner.create({
718
- containerRoot: resolvedRoot,
825
+ root: place,
719
826
  gitBin,
720
827
  timeoutMs: getBrainConfigNumber('brainGitPanelTimeoutMs', undefined, GIT_CONTROL_STATUS_TIMEOUT_MS),
721
828
  });
@@ -726,7 +833,8 @@ async function computeBranchesUncached(resolvedRoot, gitBin) {
726
833
  try {
727
834
  return await runner.branchList();
728
835
  }
729
- catch {
836
+ catch (error) {
837
+ rethrowDeviceRefusal(error);
730
838
  return null;
731
839
  }
732
840
  }
@@ -736,8 +844,9 @@ async function computeBranchesUncached(resolvedRoot, gitBin) {
736
844
  * `undefined` when the basename is empty (a source rooted at `/`), so the
737
845
  * `repoName` field is omitted rather than stamped as `''`.
738
846
  */
739
- export function repoNameFromRoot(resolvedRoot) {
740
- const name = path.basename(resolvedRoot);
847
+ export function repoNameFromRoot(target) {
848
+ const place = toGitPlace(target);
849
+ const name = placePath(place).basename(place.root);
741
850
  return name.length > 0 ? name : undefined;
742
851
  }
743
852
  /**
@@ -758,28 +867,35 @@ export function repoNameFromRoot(resolvedRoot) {
758
867
  * `repoNames` is the RAW set — consumers MUST `filterVisibleRepoNames` before
759
868
  * stamping (per-file name-visibility). Root-repos never pay the scan.
760
869
  */
761
- export function getSourceGitSummary(resolvedRoot, gitBin) {
762
- return withCache(summaryCache, resolvedRoot, GIT_CONTROL_CACHE_TTL_MS, () => computeSummaryUncached(resolvedRoot, gitBin));
870
+ export function getSourceGitSummary(target, gitBin) {
871
+ const place = toGitPlace(target);
872
+ return withCache(summaryCache, placeKey(place), GIT_CONTROL_CACHE_TTL_MS, () => computeSummaryUncached(place, gitBin));
763
873
  }
764
- async function computeSummaryUncached(resolvedRoot, gitBin) {
765
- if (!(await hasDotGit(resolvedRoot))) {
874
+ async function computeSummaryUncached(place, gitBin) {
875
+ if (!(await hasDotGit(place))) {
766
876
  // E1/F2 — the ONLY new hot-path cost (MAJOR-4b budget): the shared 30 s
767
877
  // `discoverSourceRepoRoots` (streaming opendir ≤500 + ≤25 realpath-contained
768
878
  // finds; cache shared with the route + watcher so no double scan), fail-soft
769
879
  // to []. ZERO git spawn. A non-containing source pays the same dirent walk a
770
880
  // short-circuit probe would; a containing source pays ≤25 bounded stats.
771
- const nested = (await discoverSourceRepoRoots(resolvedRoot)).filter((r) => r.rel !== '');
881
+ const nested = (await discoverSourceRepoRoots(place)).filter((r) => r.rel !== '');
772
882
  return nested.length > 0
773
883
  ? { isRepo: false, containsRepos: true, repoNames: nested.map((r) => r.rel) }
774
884
  : { isRepo: false };
775
885
  }
776
886
  // F2: the REAL repo name = the origin basename (mount-independent), falling
777
887
  // back to the checkout-dir basename for a no-remote repo / linked worktree.
778
- const repoName = (await repoNameFromOrigin(resolvedRoot)) ?? repoNameFromRoot(resolvedRoot);
888
+ const repoName = (await repoNameFromOrigin(place)) ?? repoNameFromRoot(place);
779
889
  const named = repoName ? { repoName } : {};
890
+ if (place.device) {
891
+ // The list path never spawns on a device (the caller may not run programs
892
+ // there): the branch is read from `.git/HEAD` as a file.
893
+ const branch = await readDeviceHeadBranch(place.device, place.root, placePath(place));
894
+ return { isRepo: true, ...named, ...(branch ? { branch } : {}) };
895
+ }
780
896
  let runner;
781
897
  try {
782
- runner = await GitRunner.create({ containerRoot: resolvedRoot, gitBin });
898
+ runner = await GitRunner.create({ root: place, gitBin });
783
899
  }
784
900
  catch {
785
901
  // `.git` exists but the runner could not be built — still a repo, no branch.